Skip to main content

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. 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_<segment>_<base64url> over 24 random bytes. The <segment> is currently odo for all providers regardless of POS platform: a historical artifact, not provider-derived, so treat key_prefix as an opaque value and never parse a provider out of it. 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:
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:
required
The integration the JWT acts as. Written from the key, enforced on every call.
required
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:
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.

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).
required
Your platform x-api-key. The tenant boundary is enforced on this key.
required
A partner-generated UUID. Required on this mutating call.
required
The enterprise the key belongs to. Must be a tenant the caller is authorized for.
required
Narrows the key to one brand under the enterprise.
required
Narrows the key to one branch. Validated against the caller scope. Out-of-scope returns HTTP 403.
required
Human-friendly name for the ops list, up to 120 characters. Example: ‘Al-Olaya Branch POS-360-0007’.
required
Optional future expiry. Omit or null for a non-expiring key.
The response is the ApiKeyCreated object:
required
The key id, used in lifecycle paths such as /auth/keys/{keyId}/revoke.
required
The full secret, format fddi_odo_<base64url>. Shown once. Store it the moment you receive it.
required
Prefix plus the first 4 random characters, used for quick-reject in verification and for list display.
required
Last 4 characters, for list disambiguation.
required
The derived authority level: one of enterprise, brand, branch.
required
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.
required
true for sandbox keys (mock providers, sandbox JWT claim, higher rate limit).
required
One of active, inactive.
The configured expiry, or null for a non-expiring key.
required
When the key was provisioned.
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. 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. Already-minted terminal JWTs are a separate window: after a revoke or a regenerate they stay valid for their remaining 600 second TTL (there is no JWT revocation list), so a key-compromise response must assume up to 10 minutes of residual terminal sessions.

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.
required
The platform key being exchanged. The raw key goes here, not in the body.
required
A partner-generated UUID, so a retried exchange replays the same JWT.
required
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:
required
The compact JWT. Present it as Authorization: Bearer <token> on subsequent terminal calls.
required
Always Bearer.
required
Seconds until expiry (600).
required
Absolute expiry timestamp.
required
The decoded scope claims: integration_id, enterprise_id, brand_id, branch_id, cashier_id.
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.

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:
required
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.
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.

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.