Authentication
Hold one platform x-api-key for the control plane, exchange it for a short-lived POS terminal JWT for money and session calls, and scope every credential.
Two credentials, two jobs
Feddi authenticates partner calls with two credential types. You hold one long-lived secret and mint short-lived tokens from it.
The platform x-api-key is your root credential. You provision it once, store it, and use it for the control plane: key lifecycle, SKU registration, reload-bonus config, settlement ingest, and exchanging for terminal tokens. Present it in the x-api-key HTTP header.
The POS terminal JWT is a short-lived bearer token (600 second TTL) you mint from your x-api-key. Each terminal uses it for per-terminal money and session calls: identify, POST /payments, top-up confirm, enroll verify, and offer apply or redeem. Present it in the Authorization: Bearer header.
Most-specific scope wins. An x-api-key can be enterprise, brand, or branch scoped, and the terminal JWT inherits and narrows that scope through its claims. We recommend a branch-scoped key for cashier terminals and a broader scope only for control-plane automation.
The two security schemes are defined in the OpenAPI document as ApiKeyAuth (the x-api-key) and PosTerminalJWT (the bearer). GET /openapi serves the live, CI-validated spec, so the schemes you read there are always current.
Base URL and paths
All paths in this reference are relative to the /v1/partner base.
| Environment | Base URL |
|---|---|
| Production | https://api.feddi.io/v1/partner |
| Development | https://api.dev.feddi.io/v1/partner |
Sandbox access is granted per partner agreement. Request sandbox credentials from your Feddi contact, then exchange and call exactly as you will in production.
The platform x-api-key
The x-api-key is your platform-scoped or merchant-scoped secret. It carries the tenant boundary: context.merchant_id on every call is key-enforced to a merchant this key is authorized for. It is never a free-text trust field.
The raw key format is fddi_odo_<base64url>. Feddi stores only a bcrypt hash plus a short prefix and last-four for display, so the raw key is returned exactly once at provision time and is never retrievable again.
Send it on every control-plane call:
curl https://api.feddi.io/v1/partner/capabilities \
-H "x-api-key: fddi_odo_AbC123..."
The x-api-key is your most powerful credential. Use it for the control plane and for the token exchange only. Do not put it on a POS terminal that handles money: exchange it for a terminal JWT and ship the JWT to the terminal instead.
The POS terminal JWT
The terminal JWT is an HS256 bearer token with a 600 second TTL. It is terminal-scoped and domain-separated from the consumer JWT (iss=feddi-pos-terminal, aud=feddi-api), so a consumer token can never be mistaken for a POS terminal token. It carries these scope claims, derived from the key:
The integration the JWT acts as. Written from the key, enforced on every call.
The enterprise tenant the JWT is scoped to.
The brand scope, present when the key is brand scoped or narrower.
The branch scope, present when the key is branch scoped. A branch-scoped JWT can act on one branch only.
Optional, server-validated (≤ 64 characters, no control characters). Trusted only because the server validated it at mint time, never from the request body alone.
Send it on every money and session call:
curl https://api.feddi.io/v1/partner/payments \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-H "Content-Type: application/json" \
-d '{ "meta": { "api_version": "2026-06-01" }, "context": { "merchant_id": "..." } }'
Refresh before a money call, never during one. A 600 second TTL means a JWT minted at the start of a slow checkout can expire mid-tender. Use GET /auth/token/validate to check remaining TTL before you start a checkout, not after you have a customer waiting.
Key lifecycle
Provisioning, listing, revoking, regenerating, and deleting keys are all GA. Token exchange and validate are Beta: callable today, with an additive contract that may still tighten. Pin your api_version and confirm the live enabled set at runtime via GET /capabilities and GET /openapi.
| Operation | Path | Method |
|---|---|---|
| Provision a key | /auth/keys | POST |
| List key metadata | /auth/keys | GET |
| Revoke a key | /auth/keys/{keyId}/revoke | POST |
| Regenerate a key | /auth/keys/{keyId}/regenerate | POST |
| Delete a key | /auth/keys/{keyId}/delete | POST |
| Exchange for a terminal JWT | /auth/token | POST |
| Validate a terminal JWT | /auth/token/validate | GET |
Key scoping (most-specific wins)
A key's authority is set by which of enterprise_id, brand_id, and branch_id you supply at creation. enterprise_id is required, and the other two narrow the scope.
Enterprise-scoped key
Supply enterprise_id only. The key acts on any branch under the enterprise.
Brand-scoped key
Supply enterprise_id and brand_id. The key acts on any branch under that brand.
Branch-scoped key
Supply enterprise_id, brand_id, and branch_id. The key acts on one branch only. A requested branch_id outside your own scope returns FORBIDDEN (HTTP 403).
Provision a key
POST /auth/keys provisions a scoped key. Requires the integrations CREATE permission. Idempotent on the Idempotency-Key header: a retried create with the same key and same payload replays the original record (including the same one-time raw_key bytes) with meta.idempotency_replayed: true. A same-key, different-payload request returns IDEMPOTENCY_KEY_REUSED (HTTP 422).
Your platform x-api-key. The tenant boundary is enforced on this key.
A partner-generated UUID. Required on this mutating call.
The enterprise the key belongs to. Must be a tenant the caller is authorized for.
Narrows the key to one brand under the enterprise.
Narrows the key to one branch. Validated against the caller scope. Out-of-scope returns HTTP 403.
Human-friendly name for the ops list, up to 120 characters. Example: 'Al-Olaya Branch POS-360-0007'.
Optional future expiry. Omit or null for a non-expiring key.
The response is the ApiKeyCreated object:
The key id, used in lifecycle paths such as /auth/keys/{keyId}/revoke.
The full secret, format fddi_odo_<base64url>. Shown once. Store it the moment you receive it.
Prefix plus the first 4 random characters, used for quick-reject in verification and for list display.
Last 4 characters, for list disambiguation.
The derived authority level: one of enterprise, brand, branch.
The enterprise the key belongs to.
The brand scope, when set. Null otherwise.
The branch scope, when set. Null otherwise.
The human-friendly name, when supplied.
true for sandbox keys (mock providers, sandbox JWT claim, higher rate limit).
One of active, inactive.
The configured expiry, or null for a non-expiring key.
When the key was provisioned.
curl -X POST https://api.feddi.io/v1/partner/auth/keys \
-H "x-api-key: $FEDDI_PLATFORM_KEY" \
-H "Idempotency-Key: 6f1d2c8a-3b44-4e77-9a01-2c5e7f0a9b31" \
-H "Content-Type: application/json" \
-d '{
"meta": { "partner_request_id": "9a1c4e22-7b3f-4d0a-8e21-0f9c2d3b4a5e", "occurred_at": "2026-06-05T08:00:00Z", "sent_at": "2026-06-05T08:00:01Z", "api_version": "2026-06-01" },
"context": { "merchant_id": "11111111-1111-1111-1111-111111111111" },
"enterprise_id": "11111111-1111-1111-1111-111111111111",
"brand_id": "44444444-4444-4444-4444-444444444444",
"branch_id": "22222222-2222-2222-2222-222222222222",
"label": "Al-Olaya Branch POS-360-0007"
}'
{
"ok": true,
"data": {
"key_id": "ak_01H9X2",
"raw_key": "fddi_odo_3pQ8r2kL9wX1mN7vB4tY6zC0sD5fG8hJ",
"key_prefix": "fddi_odo_3pQ8",
"key_last_four": "8hJ",
"scope": "branch",
"enterprise_id": "11111111-1111-1111-1111-111111111111",
"brand_id": "44444444-4444-4444-4444-444444444444",
"branch_id": "22222222-2222-2222-2222-222222222222",
"label": "Al-Olaya Branch POS-360-0007",
"is_sandbox": false,
"status": "active",
"expires_at": null,
"created_at": "2026-06-05T08:00:01Z"
},
"error": null,
"meta": { "request_id": "req_k1a2", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
{
"ok": false,
"data": null,
"error": { "code": "FORBIDDEN", "message": "Requested branch_id is outside the caller's scope." },
"meta": { "request_id": "req_k1a3", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
raw_key appears once, at provision time (or in an idempotent replay of that exact create). It is never retrievable again. A lost key is regenerated, not recovered. If you misplace a key, call regenerate: same scope, a new raw key shown once.
List, revoke, regenerate, delete
GET /auth/keys lists non-secret metadata only (key_id, scope, prefix, last-four, status), never the raw key. It is scoped to the calling integration, supports status and branch_id filters plus cursor pagination, and requires the integrations READ permission.
| Operation | Behavior |
|---|---|
POST /auth/keys/{keyId}/revoke | Soft-revoke. Flips status to inactive and preserves the row for audit. Re-revoking is a no-op. Requires integrations UPDATE. |
POST /auth/keys/{keyId}/regenerate | Zero-downtime rotation. Issues a new raw_key (shown once) preserving the same scope, and returns previous_key_id for the audit trail. Requires integrations UPDATE. |
POST /auth/keys/{keyId}/delete | Hard-delete for deprovision or cleanup. Modeled as POST to carry the envelope and Idempotency-Key. The audit event is preserved. Requires integrations DELETE. |
A revoke or delete returns the compact ApiKeyStatusResult (key_id, status, and revoked_at or deleted_at). The status enum is inactive after a revoke and deleted after a delete. A keyId that does not exist, or that belongs to another integration, returns NOT_FOUND (HTTP 404) with no existence leak. All three mutations accept an optional reason (≤ 200 characters) for the audit trail, for example 'scheduled_rotation' or 'terminal_decommissioned'.
Rotation is immediate, with no overlap grace window for in-flight requests still presenting the old key. After regenerate, the old raw key fails verification at once and the new one works. Sequence the terminal swap accordingly.
Exchange your key for a terminal JWT
POST /auth/token exchanges your x-api-key (in the header, never the body) for a 600 second POS terminal JWT scoped to the key. This is an authentication exchange, not a money or identity call: it returns a token, never a debit token and never customer PII. A revoked or expired key, or a key whose integration is not ACTIVE, returns INVALID_API_KEY (HTTP 401). The exchange is idempotent on the Idempotency-Key header: a retried exchange within the validity window replays the same JWT.
The platform key being exchanged. The raw key goes here, not in the body.
A partner-generated UUID, so a retried exchange replays the same JWT.
Optional cashier identifier embedded in the JWT scope. Server-validated: ≤ 64 characters, no control characters. Trusted only because it is signed into the JWT here.
The response is the PosTerminalToken object:
The compact JWT. Present it as Authorization: Bearer <token> on subsequent terminal calls.
Always Bearer.
Seconds until expiry (600).
Absolute expiry timestamp.
The decoded scope claims: integration_id, enterprise_id, brand_id, branch_id, cashier_id.
Mirrors the originating key's sandbox flag, carried as a JWT claim.
Send the exchange request with your x-api-key
POST /auth/token with the x-api-key header. Pass an Idempotency-Key so a retried exchange replays the same JWT.
Read the token and its TTL
The response carries token, token_type, expires_in, expires_at, the resolved scope, and the sandbox flag.
Cache the JWT on the terminal, keyed on expires_at
Reuse it until it nears expiry, then re-exchange. Refresh before a checkout, not during one.
curl -X POST https://api.feddi.io/v1/partner/auth/token \
-H "x-api-key: $FEDDI_PLATFORM_KEY" \
-H "Idempotency-Key: aa07c4e1-9d22-4b3f-8c10-7e5a2f0d4c88" \
-H "Content-Type: application/json" \
-d '{
"meta": { "api_version": "2026-06-01" },
"context": { "merchant_id": "11111111-1111-1111-1111-111111111111", "branch_id": "22222222-2222-2222-2222-222222222222", "terminal_id": "POS-360-0007", "cashier_id": "cashier-42" },
"cashier_id": "cashier-42"
}'
{
"ok": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 600,
"expires_at": "2026-06-05T09:10:00Z",
"scope": {
"integration_id": "33333333-3333-3333-3333-333333333333",
"enterprise_id": "11111111-1111-1111-1111-111111111111",
"brand_id": "44444444-4444-4444-4444-444444444444",
"branch_id": "22222222-2222-2222-2222-222222222222",
"cashier_id": "cashier-42"
},
"sandbox": false
},
"error": null,
"meta": { "request_id": "req_tk1", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
{
"ok": false,
"data": null,
"error": { "code": "INVALID_API_KEY", "message": "Missing, invalid, revoked, or expired x-api-key." },
"meta": { "request_id": "req_tk2", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
Keep the JWT fresh
The JWT lives 600 seconds. A long cashier shift outlives many tokens, so check freshness before a money call and re-exchange when needed.
GET /auth/token/validate pre-checks a JWT's validity and remaining TTL without performing any business operation. Present the JWT via the Authorization: Bearer header. A valid token and an invalid token both return HTTP 200: a present-but-expired token is a data answer, not a 401. The check is read-only, rate-limited to deter token-probing, and does not extend the token. The only 401 is when no bearer token is presented at all.
The response is the TokenValidation object:
true when the token is well-formed, correctly signed, the correct audience, and not expired.
Absolute expiry. Present when valid is true.
Seconds of TTL left. Present when valid is true.
The token's sandbox flag. Present when valid is true.
The decoded scope claims. Present when valid is true, null otherwise.
Why the token is invalid. One of expired, malformed, wrong_audience, bad_signature. Non-null only when valid is false.
curl https://api.feddi.io/v1/partner/auth/token/validate \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
{
"ok": true,
"data": {
"valid": true,
"expires_at": "2026-06-05T09:10:00Z",
"remaining_seconds": 412,
"sandbox": false,
"scope": {
"integration_id": "33333333-3333-3333-3333-333333333333",
"enterprise_id": "11111111-1111-1111-1111-111111111111",
"brand_id": "44444444-4444-4444-4444-444444444444",
"branch_id": "22222222-2222-2222-2222-222222222222",
"cashier_id": "cashier-42"
},
"reason": null
},
"error": null,
"meta": { "request_id": "req_tv1", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
{
"ok": true,
"data": {
"valid": false,
"expires_at": null,
"remaining_seconds": null,
"sandbox": null,
"scope": null,
"reason": "expired"
},
"error": null,
"meta": { "request_id": "req_tv2", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
A standalone token-refresh entry point is not yet available. To refresh, re-exchange your x-api-key at POST /auth/token.
Scope enforcement is per-call
Authentication answers who you are. Scope answers what you may touch. Feddi enforces both on every call, and a cross-tenant attempt never leaks existence or PII.
A read or money call outside your key's scope collapses to NOT_FOUND or FORBIDDEN, never a partial answer. A terminal JWT cannot escalate past the key it was minted from: a branch-scoped JWT acting on a sibling branch fails. context.merchant_id is checked against the key's authorized merchants on every request, never trusted from the body.
Many-merchant tenancy
If you integrate as a POS platform with many merchants, one platform integration maps to many Feddi merchant tenants. You mint per-merchant scoped keys from your platform credential rather than modeling the platform as one flat tenant.
Each merchant is a distinct Feddi tenant. Mint a scoped key per merchant (or per branch), and let context.merchant_id enforcement do the isolation. Cross-tenant access collapses to NOT_FOUND or FORBIDDEN with no existence or PII leak. See Platform and discovery.
Sandbox first
Build and self-test against your sandbox credentials before you touch production money or PII. Sandbox keys mint JWTs with sandbox: true, route to mock providers, and never touch production money or settlements. Sandbox rows are flagged and carry a higher rate limit.
Get sandbox credentials
Request a sandbox key from your Feddi contact. It returns flagged is_sandbox: true.
Exchange and build
Exchange the sandbox key for a JWT and run your full flow against mock providers. No real money or PII is touched.
Flip live
Provision a live key with POST /auth/keys, swap the base URL to https://api.feddi.io/v1/partner, and repeat your checks.
Troubleshooting a 401
Every authentication failure returns a typed string code in error.code, never a bare HTTP number. The code for a credential failure is INVALID_API_KEY.
An HTTP 401 with error.code: "INVALID_API_KEY" means the credential you presented was missing, malformed, revoked, expired, or scoped to an integration that is not ACTIVE. Work through these in order:
- Terminal JWT expired. The most common cause. The TTL is 600 seconds. Re-exchange your
x-api-keyatPOST /auth/token. Pre-check freshness withGET /auth/token/validatebefore the next checkout so you never hit this mid-tender. - Wrong header. The
x-api-keygoes in thex-api-keyheader, and the terminal JWT goes inAuthorization: Bearer. A JWT in thex-api-keyheader, or a raw key in the bearer header, fails as invalid. - Revoked or regenerated key. If you called
revokeorregenerate, the oldraw_keyis dead. Use the new one. Regenerate keeps the scope but issues a fresh secret. - Integration not ACTIVE. If the integration behind the key is suspended or not yet activated, the exchange and direct calls both return
INVALID_API_KEY. Resolve the integration state first.
A scope failure is distinct: a valid key acting outside its branch_id authority returns FORBIDDEN (HTTP 403), not INVALID_API_KEY. If you see 403, the credential is good but the target is out of scope. See Errors.
{
"ok": false,
"data": null,
"error": { "code": "INVALID_API_KEY", "message": "Missing or invalid x-api-key / POS terminal JWT." },
"meta": { "request_id": "req_4f2a", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
Where to go next
Identity and credentials
The customer credential types that resolve who the shopper is, and why identity never returns a debit token.
Idempotency and errors
The envelope, the Idempotency-Key rule, and the full typed-error model.
API conventions
The request and response envelope every authenticated call inherits.
Quickstart
Exchange a key for a JWT and make your first authenticated call.