Create Transaction
Move value on a gift card. This is the redemption endpoint — a DEBIT spends balance, REFUND returns it, and VOID / EXPIRE change the card's status.
Every transaction is recorded in an immutable ledger with the balance before and after, and the balance mutation and the ledger entry commit in a single database transaction — money can never move without its ledger entry.
Endpoint
POST /v1/giftCards/transactions
Headers
| Header | Type | Description | Required |
|---|---|---|---|
X-Client-Id | String | Your client ID | ✅ Yes |
X-Client-Secret | String | Your client secret | ✅ Yes |
Content-Type | String | application/json | ✅ Yes |
Request Body
| Field | Type | Description | Required |
|---|---|---|---|
type | String | DEBIT, REFUND, VOID or EXPIRE | ✅ Yes |
code | String | The customer's digital code | ✅ For DEBIT |
giftCardId | String (UUID) | Internal card ID | ✅ For all other types |
amount | Number | Value to move. Must be > 0 for DEBIT and REFUND; ignored for VOID and EXPIRE | ✅ Yes |
referenceId | String | Your own reference. For a REFUND this must be the referenceId of the original debit | ✅ Yes |
idempotencyKey | String | Makes this call safe to retry | ✅ Yes |
The identifier depends on the type
| Type | Identifier | Rationale |
|---|---|---|
DEBIT | code | The holder is present and initiating the spend |
REFUND | giftCardId | A back-office reversal — no customer is presenting a card |
VOID | giftCardId | An administrative cancellation |
EXPIRE | giftCardId | System-initiated |
Sending the wrong one fails validation with 400 BAD_REQUEST. The design keeps the plaintext code confined to the one operation a customer actually initiates.
Example Request — Redeem
curl --location 'https://stage-platform-exlr8-incentives.exlr8now.com/v1/giftCards/transactions' \
--header 'X-Client-Id: YOUR_CLIENT_ID' \
--header 'X-Client-Secret: YOUR_CLIENT_SECRET' \
--header 'Content-Type: application/json' \
--data '{
"code": "GC-7F3A-9K2M-4XQ8",
"type": "DEBIT",
"amount": 150,
"referenceId": "order-20260825-0042",
"idempotencyKey": "order-20260825-0042-debit-01"
}'
Response
Successful Response — 201 Created
{
"success": true,
"data": {
"transactionId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"merchantId": "b1946ac9-2c3d-4e5f-8a7b-6c5d4e3f2a1b",
"giftCardId": "9b2c4d6e-8f0a-4b1c-9d3e-5f7a1b2c3d4e",
"type": "DEBIT",
"amount": 150,
"balanceBefore": 500,
"balanceAfter": 350,
"referenceId": "order-20260825-0042",
"idempotencyKey": "order-20260825-0042-debit-01",
"createdAt": "2026-08-25T11:42:07Z"
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
transactionId | String | Ledger entry ID — store it against your order |
giftCardId | String | The card that was resolved, even when you sent a code |
type | String | The transaction type performed |
amount | Number | Value moved, rounded to 2 decimal places |
balanceBefore | Number | Balance immediately before |
balanceAfter | Number | Balance immediately after — authoritative |
referenceId | String | Your reference, echoed |
idempotencyKey | String | The key it was committed under |
createdAt | String | RFC 3339, UTC |
:::tip A DEBIT tells you the card ID
Even when you identify a card by code, the response returns giftCardId. Capture it — you cannot issue a refund without it.
:::
Debit rules
A debit is checked in this order. All of these are read from the card's template, and you can see them ahead of time via Gift Card Inquiry.
| Check | Failure |
|---|---|
Card expired, or status EXPIRED | GC_EXPIRED |
Card status not ACTIVE | GC_INACTIVE |
amount is zero or negative | BAD_REQUEST |
amount below the template's minRedemptionAmount | BAD_REQUEST |
amount exceeds remainingBalance | GC_INSUFFICIENT_BALANCE |
Partial redemption disabled and amount ≠ the full remaining balance | GC_PARTIAL_REDEMPTION_NOT_ALLOWED |
Note that a below-minimum amount comes back as a plain BAD_REQUEST, not a gift-card-specific code — check minRedemptionAmount yourself rather than relying on the error to distinguish it.
Idempotency
idempotencyKey is required on every call, and it is what makes a retry safe.
First call → debits 150 → balanceAfter: 350
Retry, same key → debits nothing → returns the original transaction, balanceAfter: 350
The guarantee holds even when a retry arrives while the first call is still in flight: the duplicate is caught by a unique index, the second attempt rolls back entirely, and the original outcome is replayed.
:::danger A reused key silently ignores the new parameters The key alone identifies the operation. If you reuse a key with a different amount or a different card, the platform does not compare the parameters and does not raise a conflict — it returns the original transaction, and the new amount is never applied.
This makes an incorrectly-derived key fail silently: a second, genuinely different debit that happens to reuse a key appears to succeed while moving no money. Derive keys per operation:
{orderId}-{operation}-{sequence} e.g. order-20260825-0042-debit-01
Deterministic, so a retry after a crash reproduces it — never uuid() generated inline, which would debit twice. And never reuse an order's debit key for its refund, or the refund will be swallowed as a replay of the debit.
:::
IDEMPOTENCY_CONFLICT is returned only in a narrow race where the duplicate record cannot be re-read afterwards. It is rare, and safe to retry.
Refunds
A refund credits value back. Its rules are stricter than most integrators expect, because REFUND must not be usable to mint value out of nothing.
The referenceId must be the one used for the original debit. The platform looks for prior DEBIT transactions on that card under exactly that referenceId, and refuses the refund if it finds none. A refund total can never exceed what was debited under that reference.
{
"giftCardId": "9b2c4d6e-8f0a-4b1c-9d3e-5f7a1b2c3d4e",
"type": "REFUND",
"amount": 150,
"referenceId": "order-20260825-0042",
"idempotencyKey": "order-20260825-0042-refund-01"
}
Note referenceId matches the debit exactly, while idempotencyKey differs. Getting this backwards is the most common failure:
| Mistake | Result |
|---|---|
A new referenceId such as order-…-refund | BAD_REQUEST — no matching debit found |
| Refunding more than was debited under that reference | BAD_REQUEST — refund exceeds the debited amount |
Reusing the debit's idempotencyKey | The debit is replayed; no refund happens |
The card must also still be ACTIVE and unexpired — you cannot refund onto an expired card.
Void and expire
VOID and EXPIRE change the card's status rather than its balance, and ignore amount entirely.
| Type | Resulting status |
|---|---|
VOID | INACTIVE |
EXPIRE | EXPIRED |
{
"giftCardId": "9b2c4d6e-8f0a-4b1c-9d3e-5f7a1b2c3d4e",
"type": "VOID",
"amount": 0,
"referenceId": "fraud-review-8871",
"idempotencyKey": "fraud-review-8871-void-01"
}
Expiry also runs automatically: an hourly job records an EXPIRE transaction against every active card past its expiry date, so the ledger stays the authoritative record of the change.
Concurrency
Balance mutations use optimistic concurrency — a change commits only if the card's version has not moved since it was read. Two simultaneous debits cannot both spend the same balance. Reads during a transaction go to the primary database, never a replica, so a debit is never computed against a stale balance.
When the compare-and-set fails, the response is 409 VERSION_MISMATCH. It covers concurrent modification, and also a change that is no longer permitted against the current balance. Retry it once with the same idempotency key; if it repeats, re-inquire and re-evaluate rather than looping.
Error Responses
| Error code | HTTP | Cause |
|---|---|---|
BAD_REQUEST | 400 | Missing field, wrong identifier for the type, non-positive amount, below the minimum redemption amount, or a refund that has no matching debit / exceeds it |
VERSION_MISMATCH | 409 | Concurrent modification, or the change is not permitted against the current balance |
IDEMPOTENCY_CONFLICT | 409 | Rare race on a duplicate key — retryable |
TEMPLATE_NOT_FOUND | 404 | The card's template no longer exists |
GC_INVALID | 422 | No card matches the code or ID |
GC_INACTIVE | 422 | The card is not ACTIVE |
GC_EXPIRED | 422 | Past its expiry date, or status EXPIRED |
GC_INSUFFICIENT_BALANCE | 422 | The requested amount exceeds the balance |
GC_PARTIAL_REDEMPTION_NOT_ALLOWED | 422 | Partial debit on a whole-balance-only card |
INTERNAL_SERVER_ERROR | 500 | Platform fault — retryable with the same key |
{
"success": false,
"errCode": "GC_INSUFFICIENT_BALANCE",
"message": "Insufficient gift card balance: insufficient balance"
}
Retry policy
Timeout or 5xx → retry the identical call, same key
409 VERSION_MISMATCH → retry once, same key
409 IDEMPOTENCY_CONFLICT → retry, same key
4xx business error → do not retry; the operation was rejected
A timeout is what idempotency exists for: you cannot know whether the debit committed before the connection dropped, so retry and let the key settle it.
Related Endpoints
- Gift Card Inquiry — check balance and rules before debiting
- Get Transaction By ID
- List Transactions
- How Redemption Works