OverviewAuthentication

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.

EnvironmentBase URL
Productionhttps://api.feddi.io/v1/partner
Developmenthttps://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:

integration_idstring
Required

The integration the JWT acts as. Written from the key, enforced on every call.

enterprise_idstring
Required

The enterprise tenant the JWT is scoped to.

brand_idstring

The brand scope, present when the key is brand scoped or narrower.

branch_idstring

The branch scope, present when the key is branch scoped. A branch-scoped JWT can act on one branch only.

cashier_idstring

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.

OperationPathMethod
Provision a key/auth/keysPOST
List key metadata/auth/keysGET
Revoke a key/auth/keys/{keyId}/revokePOST
Regenerate a key/auth/keys/{keyId}/regeneratePOST
Delete a key/auth/keys/{keyId}/deletePOST
Exchange for a terminal JWT/auth/tokenPOST
Validate a terminal JWT/auth/token/validateGET

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).

header
x-api-keystring
Required

Your platform x-api-key. The tenant boundary is enforced on this key.

header
Idempotency-Keystring
Required

A partner-generated UUID. Required on this mutating call.

body
enterprise_idstring (uuid)
Required

The enterprise the key belongs to. Must be a tenant the caller is authorized for.

body
brand_idstring (uuid)

Narrows the key to one brand under the enterprise.

body
branch_idstring (uuid)

Narrows the key to one branch. Validated against the caller scope. Out-of-scope returns HTTP 403.

body
labelstring

Human-friendly name for the ops list, up to 120 characters. Example: 'Al-Olaya Branch POS-360-0007'.

body
expires_atstring (date-time)

Optional future expiry. Omit or null for a non-expiring key.

The response is the ApiKeyCreated object:

key_idstring
Required

The key id, used in lifecycle paths such as /auth/keys/{keyId}/revoke.

raw_keystring
Required

The full secret, format fddi_odo_<base64url>. Shown once. Store it the moment you receive it.

key_prefixstring
Required

Prefix plus the first 4 random characters, used for quick-reject in verification and for list display.

key_last_fourstring
Required

Last 4 characters, for list disambiguation.

scopestring
Required

The derived authority level: one of enterprise, brand, branch.

enterprise_idstring
Required

The enterprise the key belongs to.

brand_idstring

The brand scope, when set. Null otherwise.

branch_idstring

The branch scope, when set. Null otherwise.

labelstring

The human-friendly name, when supplied.

is_sandboxboolean
Required

true for sandbox keys (mock providers, sandbox JWT claim, higher rate limit).

statusstring
Required

One of active, inactive.

expires_atstring (date-time)

The configured expiry, or null for a non-expiring key.

created_atstring (date-time)
Required

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"
  }'

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.

OperationBehavior
POST /auth/keys/{keyId}/revokeSoft-revoke. Flips status to inactive and preserves the row for audit. Re-revoking is a no-op. Requires integrations UPDATE.
POST /auth/keys/{keyId}/regenerateZero-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}/deleteHard-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.

header
x-api-keystring
Required

The platform key being exchanged. The raw key goes here, not in the body.

header
Idempotency-Keystring
Required

A partner-generated UUID, so a retried exchange replays the same JWT.

body
cashier_idstring

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:

tokenstring
Required

The compact JWT. Present it as Authorization: Bearer <token> on subsequent terminal calls.

token_typestring
Required

Always Bearer.

expires_ininteger
Required

Seconds until expiry (600).

expires_atstring (date-time)
Required

Absolute expiry timestamp.

scopeobject
Required

The decoded scope claims: integration_id, enterprise_id, brand_id, branch_id, cashier_id.

sandboxboolean
Required

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"
  }'

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:

validboolean
Required

true when the token is well-formed, correctly signed, the correct audience, and not expired.

expires_atstring (date-time)

Absolute expiry. Present when valid is true.

remaining_secondsinteger

Seconds of TTL left. Present when valid is true.

sandboxboolean

The token's sandbox flag. Present when valid is true.

scopeobject

The decoded scope claims. Present when valid is true, null otherwise.

reasonstring

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..."

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.

{
  "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