# An order could not be auto fulfilled and is being handled Source: https://developer.sodacards.com/api-reference/an-order-could-not-be-auto-fulfilled-and-is-being-handled /api-reference/webhooks.yaml webhook order.needs_attention Not a terminal state: the order will still resolve to fulfilled or refunded. It signals a delay so you can inform your customer. # An order was fulfilled and its codes are ready to fetch Source: https://developer.sodacards.com/api-reference/an-order-was-fulfilled-and-its-codes-are-ready-to-fetch /api-reference/webhooks.yaml webhook order.fulfilled # An order was refunded Source: https://developer.sodacards.com/api-reference/an-order-was-refunded /api-reference/webhooks.yaml webhook order.refunded # Delete a webhook endpoint Source: https://developer.sodacards.com/api-reference/delete-a-webhook-endpoint /api-reference/sodacards.yaml delete /v1/webhooks/{id} DeleteWebhook removes a webhook endpoint. # Get a product Source: https://developer.sodacards.com/api-reference/get-a-product /api-reference/sodacards.yaml get /v1/products/{id} GetProduct returns a single product by its id, priced for the reseller. The id is the one carried by a catalog entry. A product the reseller may not see (unlisted, hidden or inactive) is reported as not found, so an id cannot be probed to learn what exists outside the reseller's catalog. # Get an order Source: https://developer.sodacards.com/api-reference/get-an-order /api-reference/sodacards.yaml get /v1/orders/{id} GetOrder returns one of the reseller's orders by id, with its lines and current status. A live key reads live orders and a test key reads its own sandbox orders; an id that is not the caller's is reported as not found. # Get wallet balance Source: https://developer.sodacards.com/api-reference/get-wallet-balance /api-reference/sodacards.yaml get /v1/balance GetBalance returns the reseller's prepaid wallet balance, the same funds a live order is settled from. It reads only the caller's own wallet. A test key reads a fixed sandbox balance, never the real one, so a test integration can exercise the read without seeing production funds. # List catalog products Source: https://developer.sodacards.com/api-reference/list-catalog-products /api-reference/sodacards.yaml get /v1/catalog ListCatalog returns a page of the products the reseller may sell, each with the reseller's price. It is cursor-paginated: pass next_cursor from the previous page to fetch the next. A product's id is the identifier used to order it. # List orders Source: https://developer.sodacards.com/api-reference/list-orders /api-reference/sodacards.yaml get /v1/orders ListOrders returns a page of the reseller's orders, newest first. It is cursor-paginated: pass next_cursor from the previous page to fetch the next. A live key lists live orders and a test key lists its own sandbox orders. # List webhook endpoints Source: https://developer.sodacards.com/api-reference/list-webhook-endpoints /api-reference/sodacards.yaml get /v1/webhooks ListWebhooks returns the reseller's registered webhook endpoints. It never returns their signing secrets. # Ping Source: https://developer.sodacards.com/api-reference/ping /api-reference/sodacards.yaml get /v1/ping Ping confirms a key works and reports which reseller and environment it authenticated as. It is the health check a developer hits first. # Place an order Source: https://developer.sodacards.com/api-reference/place-an-order /api-reference/sodacards.yaml post /v1/orders PlaceOrder buys one or more products, settled from the reseller's prepaid wallet. It is asynchronous: the order is accepted and fulfilled in the background, so the response carries the order id and a status to poll. The request MUST carry an Idempotency-Key header, so a retried request never places a second order. # Register a webhook endpoint Source: https://developer.sodacards.com/api-reference/register-a-webhook-endpoint /api-reference/sodacards.yaml post /v1/webhooks RegisterWebhook registers a URL to receive signed event deliveries. The URL must be HTTPS and publicly routable. The response carries the signing secret once; store it, as it is never shown again. # Reveal order codes Source: https://developer.sodacards.com/api-reference/reveal-order-codes /api-reference/sodacards.yaml get /v1/orders/{id}/codes RevealOrderCodes returns the delivered codes of a completed order. Codes are available once the order is completed; a still-processing order reports that it is not ready. Reveals are rate-limited per order. # Rotate a webhook signing secret Source: https://developer.sodacards.com/api-reference/rotate-a-webhook-signing-secret /api-reference/sodacards.yaml post /v1/webhooks/{id}/rotate RotateWebhookSecret issues a new signing secret for an endpoint without interrupting deliveries: the new secret is returned once, and the previous one stays valid until prev_secret_expires_at. During that window deliveries are signed with both, so switch your verification to the new secret before the deadline. Rotating again replaces the outgoing secret rather than adding a third, so at most two secrets are ever accepted at once. # Your prepaid wallet dropped below your alert threshold Source: https://developer.sodacards.com/api-reference/your-prepaid-wallet-dropped-below-your-alert-threshold /api-reference/webhooks.yaml webhook wallet.low_balance # Your prepaid wallet was credited Source: https://developer.sodacards.com/api-reference/your-prepaid-wallet-was-credited /api-reference/webhooks.yaml webhook wallet.credited # Authentication Source: https://developer.sodacards.com/authentication Authenticate every request with a scoped API key. The API authenticates with a key you pass on every request. Keys are issued from your reseller dashboard and shown in full only once, at creation. ## Passing the key Send the key in the `X-API-Key` header: ```bash theme={null} curl https://api.sodacards.com/v1/catalog \ -H "X-API-Key: sc_live_your_key_here" ``` A `Bearer` token is also accepted: ```bash theme={null} curl https://api.sodacards.com/v1/catalog \ -H "Authorization: Bearer sc_live_your_key_here" ``` ## Key format Keys are prefixed by environment: `sc_live_` for production, `sc_test_` for the sandbox. The prefix and the last four characters are shown in your dashboard so you can recognise a key without it being re-displayed. Treat a key like a password. It is shown once. If it leaks, revoke it from the dashboard and issue a new one — revocation is immediate. ## Scopes A key carries scopes that bound what it can do: `catalog:read`, `orders:read`, `orders:write`, `wallet:read`, `webhooks:manage`. Issue a narrow key for a narrow job; a request beyond a key's scopes is refused with `403 forbidden`. ## IP allowlisting A key can be locked to a set of IP addresses or CIDR ranges. A request from any other address is refused. Leave the allowlist empty to accept any address. Our outbound webhook deliveries come from a fixed IP, which we publish so you can allowlist us in turn. # Changelog Source: https://developer.sodacards.com/changelog Changes to the SODACARDS developer API, and what counts as a breaking change. **Discover a product's purchase input fields.** `GET /v1/catalog` and `GET /v1/products/{id}` now return an `inputFields` array on each product, describing the purchase-form fields it requires (key, type, whether required, an optional regex, and a select's options). You can now build and validate an order line before placing it. Additive: products that need nothing omit the field. See [Purchase input fields](/input-fields). **Consistent order status.** `POST /v1/orders` now reports an order's status in the same public vocabulary as `GET /v1/orders/{id}` (`pending`, `processing`, `completed`, `failed`, `refunded`). A freshly placed order and the same order read back now carry the same word. See [Order lifecycle](/order-lifecycle). * **Webhook secret rotation.** `POST /v1/webhooks/{id}/rotate` issues a new signing secret while the previous one keeps verifying for a grace window, so you can roll a secret without dropping events. * **Wallet balance.** `GET /v1/balance` returns the key's spendable balance. * **Truthful sandbox.** A sandbox order is fulfilled to a real terminal status with deterministic `TEST-` codes, and a test key may force an error path with the `X-Test-Scenario` header. ## Versioning policy The API is versioned in its path (`/v1`). We add to it often and remove from it rarely. **Non-breaking changes** ship at any time on `/v1`, and your integration should tolerate them: * a new endpoint; * a new optional field in a request; * a new field in a response; * a new value in an enum, a new error `code`, or a new webhook event type; * a new, optional request header. Build your client to ignore response fields it does not recognize, and to treat an unknown error `code` by its HTTP status. **Breaking changes** do not happen silently on `/v1`. Any of the following is announced here with advance notice, and, when unavoidable, carried on a new version path: * removing or renaming a field, endpoint, error `code` or event type; * changing a field's type or the meaning of a value; * making an optional request field required, or tightening validation; * changing authentication or the signature scheme. Your API keys, the `operationId` of each endpoint (the name our SDKs generate), and the machine `code` on each error are stable contracts we treat as breaking to change. # Errors Source: https://developer.sodacards.com/errors How the API reports failures, and the machine codes you can branch on. Every error is a [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem document, served as `application/problem+json`. The HTTP status frames the category; the machine `code` names the exact reason and is the stable value your code should branch on. ```json theme={null} { "type": "https://developer.sodacards.com/errors#order_not_found", "title": "Order not found", "status": 404, "code": "order_not_found", "detail": "The requested order does not exist.", "request_id": "7aef8d621a89/FvH5PzwhbR-000692" } ``` The error body is **snake\_case** (`request_id`, not `requestId`), unlike the camelCase business responses. The set of codes is **open**: branch on the codes you know and fall back to the HTTP status for any you do not. A `type` link to a code this page does not list still lands here. ## Testing error paths With a **test key**, force a specific failure deterministically by sending the `X-Test-Scenario` header set to one of `insufficient_balance`, `order_rejected` or `order_amount_exceeded`, instead of waiting for the condition to occur. A live key that carries the header is refused with `test_scenario_not_allowed`. ## Authentication and access ### api\_key\_required `401` — No API key was provided. Send it in the `X-API-Key` header or as a Bearer token. ### invalid\_api\_key `401` — The API key is missing, malformed, revoked or expired. ### ip\_not\_allowed `403` — This key may not be used from your address. Check the key's IP allowlist. ### insufficient\_scope `403` — This key does not have the scope required for this action. ### forbidden `403` — This key may not perform this action. ### reseller\_not\_approved `403` — This account is not an approved reseller. ### test\_scenario\_not\_allowed `400` — The `X-Test-Scenario` header is only honored for a test key. ## Rate limiting ### rate\_limited `429` — You have exceeded the request rate for this key. Retry after the delay in `Retry-After`. See [Rate limits](/rate-limits). ### reveal\_rate\_limited `429` — Codes for this order have been revealed too often. Retry after a short delay. ## Request validation ### invalid\_request `400` — The request was not valid. Check the parameters and try again. ### malformed\_identifier `400` — An identifier in your request is not a valid UUID. Check the id and try again. A well-formed id that does not exist is a `404`, not this. ### invalid\_cursor `400` — The pagination cursor is not valid. See [Pagination](/pagination). ### not\_found `404` — The requested resource does not exist. ### product\_not\_found `404` — The requested product does not exist or is not available to you. ## Idempotency ### idempotency\_key\_missing `400` — This request requires an `Idempotency-Key` header. See [Idempotency](/idempotency). ### idempotency\_key\_reused `422` — This `Idempotency-Key` was already used for a request with a different body. ### request\_in\_progress `409` — A request with this `Idempotency-Key` is still being processed. Retry after a short delay. ## Orders ### order\_rejected `400` — The order could not be placed. Check the products, quantities and [input fields](/input-fields). ### insufficient\_balance `402` — Your wallet balance does not cover this order. Top up and try again. ### order\_amount\_exceeded `422` — The order total exceeds the maximum allowed for a single order. ### order\_not\_found `404` — The requested order does not exist. ### order\_not\_ready `409` — The order is still being fulfilled. Poll the order and retry when it is `completed`. ### codes\_unavailable `422` — This order has no codes to reveal. ## Webhooks ### invalid\_webhook\_url `400` — The webhook URL is not a valid absolute URL. ### webhook\_url\_not\_https `400` — A webhook URL must use `https`. ### webhook\_url\_not\_public `400` — A webhook URL must point at a publicly routable address. ### invalid\_webhook\_events `400` — Subscribe to at least one known event type. ### webhook\_not\_found `404` — The requested webhook endpoint does not exist. ### webhooks\_unavailable `503` — Webhook registration is not configured on this service. ## Service ### unavailable `503` — The service is temporarily unavailable. Retry after a short delay. ### internal\_error `500` — Something went wrong on our side. The cause is never leaked in the body; quote the `request_id` when you contact support. # Idempotency Source: https://developer.sodacards.com/idempotency Make order placement safe to retry. Placing an order is a money movement, so it must be safe to retry. Send an `Idempotency-Key` header on `POST /v1/orders` and a retry with the same key never charges you twice. ```bash theme={null} curl https://api.sodacards.com/v1/orders \ -H "X-API-Key: sc_live_your_key_here" \ -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \ -H "Content-Type: application/json" \ -d '{"lines": [{"product_id": "a0c4d81e-d668-4dd4-9333-8551cadff904", "quantity": 1}]}' ``` ```json Response theme={null} { "order": { "id": "bbc26d24-cf7d-42a4-899e-92cbe819729d", "status": "completed", "total": { "amount": "395", "currency": "XOF" } } } ``` Send that request again with the same `Idempotency-Key` and you get the same order (`bbc26d24-…`) back, charged once — never a second order. Use a fresh unique key (a UUID) per distinct order, and reuse the same key when retrying that same order after a network error or timeout. ## How it behaves * **Same key, same body** → you get the original order back, charged exactly once. * **Same key, different body** → `422 idempotency_key_reused`. The key is bound to its first request; do not reuse a key for a different order. * **Same key, still processing** → `409 request_in_progress`. The first request has not finished; retry after a short delay. * **No key on a mutating request** → `400 idempotency_key_missing`. Store the idempotency key with your order record before you send the request, so a crash mid-request still lets you retry with the same key. # Purchase input fields Source: https://developer.sodacards.com/input-fields Discover the fields a product needs, submit them on an order, and handle a product that needs none. Some products need extra information at purchase time. A game top-up needs the player's account — for example a player id and a server. A gift card needs nothing. The API tells you which, so you can build and validate an order line before you place it. ## Discover a product's fields Read a product (`GET /v1/products/{id}`, or any item from `GET /v1/catalog`). When it needs inputs, it carries an `inputFields` array. When it needs none, the field is absent. A top-up product, copied from a real call: ```json theme={null} { "product": { "id": "f5baa307-b104-4a08-a6d8-a2b51b4b3292", "name": "100 Diamonds", "faceValue": { "amount": "100", "currency": "USD" }, "price": { "amount": "700", "currency": "XOF" }, "minQuantity": 1, "purchasable": true, "inputFields": [ { "key": "player_id", "type": "text", "required": true, "regex": "^[0-9]{6,}$" }, { "key": "server", "type": "select", "required": true, "options": [ { "value": "asia", "label": "Asia" }, { "value": "asia-1", "label": "Asia Server 1", "parentValue": "asia" } ] } ] } } ``` ### Reading a field spec The field's machine name. This is the key to use in `inputFields` on the order line. How to render and validate the value: `text`, `number` or `select`. When `true`, the order line must carry this field with a non-empty value. For a `text` or `number` field, a regular expression the value must match. Absent when there is no pattern constraint. For a `select` field, the allowed values in display order. Each option has a `value` (what you submit), a `label` (for display), and an optional `parentValue`. A `parentValue` gates a cascade: the option applies only when the parent field's option of that value is chosen — for example a server that belongs to a chosen region. ## Submit the fields Pass `input_fields` on the order line, keyed by each field's `key`. For a `select`, submit the option's `value`. Copied from a real call: ```bash theme={null} curl -X POST https://api.sodacards.com/v1/orders \ -H "X-API-Key: sc_test_..." \ -H "Idempotency-Key: your-unique-key" \ -H "Content-Type: application/json" \ -d '{ "lines": [ { "product_id": "f5baa307-b104-4a08-a6d8-a2b51b4b3292", "quantity": 1, "input_fields": { "player_id": "123456", "server": "asia-1" } } ] }' ``` The order is accepted and, in the sandbox, fulfilled at once: ```json theme={null} { "order": { "id": "572e24c6-270e-46a0-9b85-edd7eeb5290e", "status": "completed", "total": { "amount": "700", "currency": "XOF" } } } ``` ## Validation The server validates `input_fields` before accepting the order: * every **required** field must be present and non-empty; * a value must match its field's **regex** when one is set; * an **unknown** key is rejected. A line that fails any of these is refused with `order_rejected` ([see Errors](/errors)). Validate against the field specs on your side first, so a rejection is the exception, not the flow. ## A product that needs nothing Most gift cards need no input. Their product has no `inputFields`, and the order line carries only the product and quantity: ```bash theme={null} curl -X POST https://api.sodacards.com/v1/orders \ -H "X-API-Key: sc_test_..." \ -H "Idempotency-Key: your-unique-key" \ -H "Content-Type: application/json" \ -d '{ "lines": [ { "product_id": "a0c4d81e-d668-4dd4-9333-8551cadff904", "quantity": 1 } ] }' ``` # Introduction Source: https://developer.sodacards.com/introduction Sell gift cards and game top-ups from your own systems with the SODACARDS API. The SODACARDS API lets you place orders for gift cards and game top-ups, read their status, fetch the delivered codes, and receive webhooks when an order is fulfilled — all from your own backend, priced at your reseller rates and settled from your prepaid wallet. ## How it works Every request carries an API key. Keys are scoped and can be locked to a set of IP addresses. See [Authentication](/authentication). `POST /v1/orders` with a `lines` array — each line a product and a quantity, plus any [input fields](/input-fields) the product needs. The order settles from your wallet atomically and starts fulfilling; follow it on the [order lifecycle](/order-lifecycle). When the order is fulfilled you receive an `order.fulfilled` [webhook](/webhooks); fetch the codes from the authenticated codes endpoint. ## Base URL All requests go to a single base URL over HTTPS: ```text theme={null} https://api.sodacards.com/v1 ``` ## Live and sandbox Your API key's environment decides the mode. A `sc_test_` key runs in the sandbox: orders are validated and priced exactly like live, but no wallet is charged and the codes returned are deterministic test values. A `sc_live_` key runs against your real wallet and real supply. ## Conventions * Money is self-describing: each amount carries its `currency` and `minorUnitExponent`, so the major-unit value is `amount / 10^minorUnitExponent` and you never assume the currency. The API settles in FCFA (XOF, which has no minor unit), so an amount of `5000` means 5000 FCFA, not 50.00. * Timestamps are RFC 3339 (`2026-07-25T09:52:00Z`). * List endpoints are cursor-paginated. See [Pagination](/pagination). ## Next steps * [Quickstart](/quickstart) — place your first order and fetch its codes. * [Order lifecycle](/order-lifecycle) — the statuses an order moves through, and the webhook for each. * [Purchase input fields](/input-fields) — the values a product needs on an order. * [SDKs](/sdks) — official client libraries for TypeScript, Python, Go and PHP. * [Changelog](/changelog) — API changes, and what counts as a breaking change. # Order lifecycle Source: https://developer.sodacards.com/order-lifecycle The statuses an order moves through, and the webhook event for each. An order moves through a small, stable set of **public statuses**. Every read of an order — `GET /v1/orders/{id}`, `GET /v1/orders`, and the `POST /v1/orders` response — reports one of these five words. They are deliberately narrower than our internal fulfillment states, so your integration never depends on an internal detail that could change. ## The five statuses | Status | Meaning | Terminal | | ------------ | --------------------------------------------------- | -------- | | `pending` | The order is accepted; payment is not yet settled. | No | | `processing` | The order is paid and being fulfilled. | No | | `completed` | The order is fulfilled and its codes are available. | **Yes** | | `failed` | The order could not be fulfilled. | **Yes** | | `refunded` | The order was refunded. | **Yes** | A **terminal** status never changes again — stop polling once you see one. The only exception is that a `completed` order can later become `refunded` if it is refunded by support. ## How an order progresses ```mermaid theme={null} stateDiagram-v2 [*] --> pending pending --> processing pending --> failed processing --> completed processing --> refunded completed --> refunded ``` A sandbox order (placed with a test key) is fulfilled synchronously: it is born `completed` with deterministic fake codes, so you can exercise the full read-and-reveal flow without waiting. ## Example A completed order read with a test key, copied from a real call: ```json theme={null} { "order": { "id": "572e24c6-270e-46a0-9b85-edd7eeb5290e", "status": "completed", "total": { "amount": "700", "currency": "XOF" }, "createdAt": "2026-08-04T16:25:37Z", "lines": [ { "productId": "f5baa307-b104-4a08-a6d8-a2b51b4b3292", "name": "100 Diamonds", "unitPrice": { "amount": "700", "currency": "XOF" }, "quantity": 1, "lineTotal": { "amount": "700", "currency": "XOF" }, "inputFields": { "player_id": "123456", "server": "asia-1" } } ] } } ``` ## Webhook events Subscribe a [webhook](/webhooks) and we deliver a signed event when an order reaches a notable state: | Order reaches | Webhook event | | --------------------------------------------- | ----------------------------------- | | `completed` | `order.fulfilled` | | being handled by support (still `processing`) | `order.needs_attention` | | `refunded` | `order.refunded` | | `failed` | **none** — poll to detect a failure | **A `failed` order emits no webhook**, and neither do `pending` or `processing`. A subscriber that never polls will wait forever for an order that failed: to detect a failure, **poll the order** until it reaches a terminal status. More generally, webhook delivery is at-least-once and best-effort — treat a webhook as a fast signal, never the sole source of truth, and reconcile by polling. The success event is named `order.fulfilled`, while the status you read is `completed`: the two name the same outcome from two vocabularies (events and statuses). There is no `order.completed` event and no `fulfilled` status. ## Internal states, for reference Our fulfillment engine uses a longer set of states internally. You never receive these, but the table maps them to the public status you do receive, so a value seen in a support conversation is unambiguous: | Internal state | Public status | | --------------------------------------- | ------------- | | `pending_payment` | `pending` | | `paid`, `fulfilling`, `needs_attention` | `processing` | | `delivered` | `completed` | | `failed` | `failed` | | `refunded` | `refunded` | # Pagination Source: https://developer.sodacards.com/pagination Page through list endpoints with opaque cursors. List endpoints — such as `GET /v1/catalog` and `GET /v1/orders` — are cursor-paginated. Each response carries the page of results, whether more remain, and the cursor to fetch the next page: ```json theme={null} { "data": [ /* ... */ ], "hasMore": true, "nextCursor": "MTc4NTg2MDczNzUyOTc5MTAwMDo1NzJlMjRjNi0yNzBlLTQ2YTAtOWI4NS1lZGQ3ZWViNTI5MGU" } ``` To fetch the next page, pass `nextCursor` back as the `cursor` query parameter: ```bash theme={null} curl "https://api.sodacards.com/v1/orders?limit=1&cursor=MTc4NTg2MDczNzUyOTc5MTAwMDo1NzJlMjRjNi0yNzBlLTQ2YTAtOWI4NS1lZGQ3ZWViNTI5MGU" \ -H "X-API-Key: sc_test_your_key_here" ``` Stop when `hasMore` is `false` (`nextCursor` is empty on the last page). * `limit` bounds the page size; it is clamped to a sane maximum. * The cursor is **opaque** — do not parse or construct it. Pass back exactly what you received. A malformed cursor is refused with `invalid_cursor`. * Cursors are keyset-based, so paging stays correct even as new records arrive. # Quickstart Source: https://developer.sodacards.com/quickstart Place your first order and fetch its codes in a few minutes. This walks through placing an order with a sandbox key and fetching its codes. ## 1. Create a sandbox key In your reseller dashboard, create an API key in the **test** environment. It is prefixed `sc_test_` and shown once — copy it now. ## 2. Browse the catalog Each catalog item is a purchasable denomination. Its `id` is what you order. ```bash cURL theme={null} curl https://api.sodacards.com/v1/catalog \ -H "X-API-Key: sc_test_your_key_here" ``` ```python Python theme={null} import requests resp = requests.get( "https://api.sodacards.com/v1/catalog", headers={"X-API-Key": "sc_test_your_key_here"}, ) print(resp.json()) ``` ```js Node theme={null} const resp = await fetch("https://api.sodacards.com/v1/catalog", { headers: { "X-API-Key": "sc_test_your_key_here" }, }); console.log(await resp.json()); ``` ```json Response theme={null} { "data": [ { "id": "a0c4d81e-d668-4dd4-9333-8551cadff904", "name": "25 TL", "faceValue": { "amount": "25", "currency": "TRY" }, "price": { "amount": "395", "currency": "XOF" }, "bonus": "0", "minQuantity": 1, "purchasable": true } ] } ``` Some products require purchase-form values (a game top-up needs a player id, for example). They carry an `inputFields` array — see [Purchase input fields](/input-fields). This gift card needs none. ## 3. Place an order An order carries one or more `lines`, each a product `id` and a `quantity`. Include an `Idempotency-Key` so a retry never places the order twice (see [Idempotency](/idempotency)). ```bash cURL theme={null} curl https://api.sodacards.com/v1/orders \ -H "X-API-Key: sc_test_your_key_here" \ -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \ -H "Content-Type: application/json" \ -d '{"lines": [{"product_id": "a0c4d81e-d668-4dd4-9333-8551cadff904", "quantity": 1}]}' ``` ```python Python theme={null} import requests resp = requests.post( "https://api.sodacards.com/v1/orders", headers={ "X-API-Key": "sc_test_your_key_here", "Idempotency-Key": "7c9e6679-7425-40de-944b-e07fc1f90ae7", }, json={"lines": [{"product_id": "a0c4d81e-d668-4dd4-9333-8551cadff904", "quantity": 1}]}, ) print(resp.json()) ``` ```json Response theme={null} { "order": { "id": "bbc26d24-cf7d-42a4-899e-92cbe819729d", "status": "completed", "total": { "amount": "395", "currency": "XOF" } } } ``` The response carries the order `id` and its `status`. In the sandbox an order is fulfilled at once, so it is born `completed`; a live order settles asynchronously — follow it on the [order lifecycle](/order-lifecycle). ## 4. Fetch the codes Once the order is `completed` — you receive an [`order.fulfilled` webhook](/webhooks), or you poll `GET /v1/orders/{id}` — fetch the codes: ```bash theme={null} curl https://api.sodacards.com/v1/orders/4102a9c6-fff4-4a15-a617-16e615f4c1a2/codes \ -H "X-API-Key: sc_test_your_key_here" ``` ```json Response theme={null} { "codes": [ { "label": "25 TL", "code": "TEST-488acb9426f25b39" } ] } ``` In the sandbox the codes are deterministic `TEST-` values. Switch to a `sc_live_` key to order against your real wallet and supply. # Rate limits Source: https://developer.sodacards.com/rate-limits How request rate is bounded per key, and how to back off. Requests are rate-limited **per API key**. The budget is advertised on every response in the `RateLimit-Policy` header: ```http theme={null} RateLimit-Policy: "per-key";q=600;w=60 ``` That is **600 requests per 60-second window** for the key. The window is a rolling budget, not a fixed clock minute. ## When you exceed it A request over budget is refused with `429 Too Many Requests`. The response carries a `Retry-After` header — a whole number of seconds to wait — alongside the usual `RateLimit-Policy`: ```http theme={null} HTTP/2 429 RateLimit-Policy: "per-key";q=600;w=60 Retry-After: 12 Content-Type: application/problem+json ``` ```json theme={null} { "type": "https://developer.sodacards.com/errors/rate_limited", "title": "Too many requests", "status": 429, "code": "rate_limited", "detail": "You have exceeded the request rate for this key. Retry after the indicated delay.", "request_id": "..." } ``` ## Backing off * Wait for the number of seconds in `Retry-After` before retrying. * Prefer a small concurrency and steady pace over bursts. * Spread scheduled work; do not fire a whole batch at the top of the minute. * Reuse a single key's budget deliberately across your workers. Reveal has its own, tighter throttle. Revealing an order's codes too often returns `reveal_rate_limited`; see [Errors](/errors). # SDKs Source: https://developer.sodacards.com/sdks Official client libraries for TypeScript, Python, Go and PHP. We publish an official SDK for four languages. Each is **generated from this API's OpenAPI specification and regenerated whenever the API changes**, so a client never drifts from the contract. Under the hood every SDK sends the same requests you see in the [API reference](/api-reference) — pick the one for your stack. Every call is authenticated with an API key from the developer dashboard, prefixed `sc_live_` (production) or `sc_test_` (sandbox). Load it from an environment variable — never commit a key. The examples below read `SODACARDS_API_KEY`. ## TypeScript ```bash theme={null} npm add @sodacards/sdk ``` ```typescript theme={null} import { Sodacards } from "@sodacards/sdk"; const sodacards = new Sodacards({ apiKeyAuth: process.env.SODACARDS_API_KEY }); const catalog = await sodacards.listCatalog(); console.log(catalog); ``` Published as an ES module. `pnpm add`, `bun add` and `yarn add` work too. ## Python ```bash theme={null} pip install sodacards ``` ```python theme={null} import os import sodacards config = sodacards.Configuration(host="https://api.sodacards.com") config.api_key["ApiKeyAuth"] = os.environ["SODACARDS_API_KEY"] client = sodacards.DefaultApi(sodacards.ApiClient(config)) catalog = client.list_catalog() print(catalog) ``` Requires Python 3.10 or newer. ## Go ```bash theme={null} go get github.com/SODACARDS/sodacards-go ``` ```go theme={null} client := sodacards.NewAPIClient(sodacards.NewConfiguration()) auth := context.WithValue(context.Background(), sodacards.ContextAPIKeys, map[string]sodacards.APIKey{"ApiKeyAuth": {Key: os.Getenv("SODACARDS_API_KEY")}}) catalog, _, err := client.DefaultAPI.ListCatalog(auth).Execute() ``` ## PHP ```bash theme={null} composer require sodacards/sdk ``` ```php theme={null} $config = Sodacards\Configuration::getDefaultConfiguration() ->setApiKey('X-API-Key', getenv('SODACARDS_API_KEY')); $client = new Sodacards\Api\DefaultApi(new GuzzleHttp\Client(), $config); $catalog = $client->listCatalog(); ``` ## Per-endpoint examples Every endpoint in the [API reference](/api-reference) carries a ready-to-copy snippet in all four languages — placing an order, revealing codes, registering a webhook, and the rest. The SDKs also surface typed errors that mirror the machine [error codes](/errors), so you branch on the same values the raw API returns. # Webhook IP addresses Source: https://developer.sodacards.com/webhook-ips The address our webhook deliveries come from, and why it does not replace signature verification. Every webhook delivery to your endpoint originates from a single, stable address: **`164.90.161.33`** If your endpoint sits behind an IP allowlist or firewall, allow this address so deliveries reach you. An IP allowlist is a coarse network filter, not authentication. It does **not** prove a request came from us: source addresses can be spoofed, and shared infrastructure can send from an allowed range. Always [verify the signature](/webhooks) on every event, whether or not you also filter by IP. The IP allowlist complements the signature; it never replaces it. This address may change as our infrastructure evolves. When it does, we announce it on the [changelog](/changelog) with advance notice, so treat it as configuration, not a constant to hardcode beyond your firewall rules. # Webhooks Source: https://developer.sodacards.com/webhooks Receive signed events when an order is fulfilled or refunded, and verify them. Register an endpoint to receive events as they happen instead of polling. SODACARDS POSTs a signed JSON envelope to your URL; you verify the signature and act on it. Webhooks are a fast signal, not the sole source of truth: not every transition emits one — a [`failed` order sends no event](/order-lifecycle#webhook-events) — and delivery is at-least-once and best-effort. Poll the order for a failure, and reconcile periodically. ## Events | Event | When | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `order.fulfilled` | An order was fulfilled and its codes are ready to fetch. | | `order.needs_attention` | An order could not be auto-fulfilled and is being handled. Not terminal: it will still resolve to fulfilled or refunded. | | `order.refunded` | An order was refunded. | | `wallet.credited` | Your prepaid wallet was credited. | | `wallet.low_balance` | Your prepaid wallet dropped below your alert threshold. | The payload is customer-safe. An order event carries the order id and status; a wallet event carries the amounts in FCFA. A code, cost or supplier is never sent: fetch the codes from the authenticated endpoint once you receive `order.fulfilled`. ```json theme={null} { "id": "evt_1a2b3c", "type": "order.fulfilled", "created_at": "2026-07-25T09:52:00Z", "data": { "order_id": "ord_9x8y7z", "status": "fulfilled" } } ``` ## Registering an endpoint `POST /v1/webhooks` with your HTTPS URL and the events you want. The response returns a signing secret prefixed `whsec_`, shown **once** — store it now. ```bash theme={null} curl https://api.sodacards.com/v1/webhooks \ -H "X-API-Key: sc_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{"url": "https://hooks.example.com/sodacards", "events": ["order.fulfilled", "order.refunded"]}' ``` Your endpoint must be HTTPS and publicly reachable. We refuse a URL that resolves to a private, loopback or link-local address. ## Verifying a delivery Each request carries a `Sodacards-Signature` header: ```http theme={null} Sodacards-Signature: t=1700000000,v1=38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789 Sodacards-Event-Id: evt_1a2b3c Sodacards-Event-Type: order.fulfilled ``` `v1` is `HMAC-SHA256(secret, ".")`, hex-encoded, keyed on your `whsec_` secret. To verify: 1. Read `t` and `v1` from the header. 2. Reject the delivery if `t` is more than five minutes from now (replay defence). 3. Recompute the HMAC over the **exact raw body bytes** and compare it to `v1` in constant time. During a secret rotation the header may carry several comma-separated `v1=` values; accept the delivery if any one matches. ```python Python theme={null} import hashlib import hmac import time def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool: timestamp = None signatures = [] for part in header.split(","): key, _, value = part.partition("=") if key == "t": timestamp = int(value) elif key == "v1": signatures.append(value) if timestamp is None or abs(time.time() - timestamp) > tolerance: return False signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, sig) for sig in signatures) ``` ```javascript Node theme={null} import crypto from "node:crypto"; export function verify(secret, header, rawBody, toleranceSec = 300) { let timestamp; const signatures = []; for (const part of header.split(",")) { const [key, value] = part.split("="); if (key === "t") timestamp = parseInt(value, 10); else if (key === "v1") signatures.push(value); } if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false; const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); return signatures.some( (sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)), ); } ``` ```php PHP theme={null} $tolerance) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } ``` ```go Go theme={null} package sodacards import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "strings" "time" ) func Verify(secret, header string, rawBody []byte, tolerance time.Duration) bool { var timestamp int64 var signatures []string for _, part := range strings.Split(header, ",") { key, value, ok := strings.Cut(part, "=") if !ok { continue } switch key { case "t": timestamp, _ = strconv.ParseInt(value, 10, 64) case "v1": signatures = append(signatures, value) } } if timestamp == 0 { return false } if delta := time.Since(time.Unix(timestamp, 0)); delta > tolerance || delta < -tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(strconv.FormatInt(timestamp, 10) + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) for _, signature := range signatures { if hmac.Equal([]byte(signature), []byte(expected)) { return true } } return false } ``` Verify over the **raw body**, before any JSON parsing re-serialises it. A re-encoded body will not match the signature. ## Delivery, retries and de-duplication Delivery is at-least-once, so the same event may arrive more than once: **de-duplicate on `Sodacards-Event-Id`**. Return any `2xx` to acknowledge; a non-2xx, a timeout or a connection error is retried on a backoff schedule. An endpoint that fails continuously for several days is automatically disabled, and you are notified. Respond quickly — acknowledge first, then do your work asynchronously — so a slow handler does not look like a failed delivery.