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 platformx-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.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
Thex-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 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.
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 yourapi_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 ofenterprise_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.
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.
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.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 toNOT_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.Sandbox first
Build and self-test against your sandbox credentials before you touch production money or PII. Sandbox keys mint JWTs withsandbox: 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 inerror.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.