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