The cardyay API
Everything your own systems need to work with the gift card you sell on cardyay: look a card up and redeem it at your till or checkout, send a customer a link to pay when your own checkout cannot take the payment, and hear about every sale the moment it happens.
Getting started
- Apply to sell and wait for your business to be approved. The API answers
403until then. - In your dashboard, open Developers and create an API key. The account owner does this. The key is shown once.
- Call the API from your server. Start with the request below: it tells you which business the key belongs to and lists your cards.
curl https://cardyay.com/api/v1/me \
-H "Authorization: Bearer cyk_your_key"The base address is https://cardyay.com/api/v1. Every call is made over HTTPS from your server. Never put a key in a web page or a mobile app: anyone holding it can redeem your cards.
Authentication
Send your key in the Authorization header as a Bearer token. Keys start with cyk_.
Authorization: Bearer cyk_your_key- A key acts for one business and can only ever see that business’s cards, redemptions and pay requests.
- We store a fingerprint of the key, not the key. If you lose it, revoke it in your dashboard and create a new one.
- A missing, wrong or revoked key answers
401 unauthorized. A key of a business that is not approved answers403 business_not_approved.
Requests, money and limits
- Send and receive JSON. A request with a body needs
Content-Type: application/json. - Amounts are whole numbers in minor units: cents for USD, pence for GBP.
2500is $25.00. Each amount comes with its currency. - Times are ISO 8601 in UTC, such as
2026-11-12T10:15:00.000Z. Webhook timestamps are Unix seconds. - A card’s code is never returned by the API and never sent in a webhook. A code is as good as money, so cards are named by their
idand the last four characters of the code. - Each key may make 120 requests a minute. Past that the API answers
429 rate_limitedwith aRetry-Afterheader in seconds. - Codes that match no card are counted: after 60 in ten minutes, lookups and redemptions with that key answer
429 too_many_missesuntil the ten minutes are over. - No response is cacheable: every one carries
Cache-Control: no-store.
Every error has the same shape, with a stable code for your program and a message for a person:
{
"error": {
"code": "insufficient",
"message": "The card does not have enough balance for that amount."
}
}The card object appears in several answers and always reads the same:
{
"id": "crd_4k8m2x9q7w3e5r6t1y0p8s2d",
"last4": "QRTW",
"status": "active",
"currency": "USD",
"initialMinor": 5000,
"balanceMinor": 5000,
"expiresAt": "2027-11-12T10:15:00.000Z",
"brand": { "slug": "fernwood", "name": "Fernwood Tea" }
}| Field | Type | What it is |
|---|---|---|
| id | string | The card. Safe to store and to show. |
| last4 | string or null | Last four characters of the code. Null for a card with no code (see "credit" below). |
| status | string | active can be spent. redeemed is used in full. expired passed its date. void was cancelled, for example after a refund. |
| currency | string | ISO 4217 code. |
| initialMinor | integer | What the card was sold for. |
| balanceMinor | integer | What is left on it. |
| expiresAt | string or null | When it stops working. Null for a card that never expires. |
| brand | object | Which of your cards it is: slug (its link name) and name. |
Could not take a payment? Send them here
Sometimes your own checkout cannot take a customer’s money: their card is declined by your processor, you do not accept their payment method, or you do not take cards at all. Instead of losing the sale, hand the payment to cardyay. You get a link for that one customer and that one amount, send them to it, and we tell your server when it is paid.
This works best with a card set up as “The payment tops up something on our side” (mode: "credit"): no code is issued, the payment simply arrives with your reference attached. With a code card the customer is e-mailed a gift card code instead, which they then spend with you.
- Your checkout fails. Keep the cart or order open on your side and note its id.
- Create a pay request. Your server calls POST /requests with the card’s link name, the customer’s e-mail, the amount, your order id as
reference, and areturnUrlon your own website. - Send the customer to the link. Redirect their browser to the
urlin the answer. The page shows your card and the amount; they pay by card. - We tell your server. As soon as the payment goes through we POST a signed
card.soldevent to your webhook. Itsdata.order.referenceis your order id anddata.order.requestIdis the pay request’s id. Mark the order paid when you receive it. - The customer comes back. After paying they see a button that returns them to your
returnUrl, exactly as you gave it. Do not treat that visit as proof of payment: rely on the webhook, or ask GET /requests/{id}.
curl -X POST https://cardyay.com/api/v1/requests \
-H "Authorization: Bearer cyk_your_key" \
-H "Content-Type: application/json" \
-d '{
"card": "kestrel-play",
"email": "sam@example.com",
"name": "Sam",
"amount": 2000,
"reference": "cart-58213",
"message": "Top-up for player 88213",
"returnUrl": "https://kestrelplay.example/checkout/58213/done"
}'{
"id": "req_2b4d6f8h0j1k3m5n7p9q2r4s",
"url": "https://cardyay.com/r/req_2b4d6f8h0j1k3m5n7p9q2r4s?k=5f0c…",
"expiresAt": "2026-11-26T10:15:00.000Z"
}A link works until it is paid, cancelled or its expiresAt passes. It can be paid once. Without a webhook you can poll GET /requests/{id} until status is paid.
Endpoints
GET/api/v1/me
The business the key belongs to, and its cards.
curl https://cardyay.com/api/v1/me \
-H "Authorization: Bearer cyk_your_key"{
"business": {
"id": "biz_6h8k2m4p6r8t0w2y4a6c8e0g",
"name": "Fernwood Tea",
"status": "approved",
"website": "https://fernwood.example"
},
"cards": [
{
"slug": "fernwood",
"name": "Fernwood Tea",
"status": "live",
"mode": "code",
"currency": "USD",
"values": { "type": "fixed", "amounts": [1500, 2500, 5000, 10000] }
}
]
}A card’s status is draft, in_review, live, paused or rejected; it is on sale when live. values is either a list ({ "type": "fixed", "amounts": [...] }) or a range ({ "type": "range", "min": 1000, "max": 50000 }).
POST/api/v1/cards/lookup
What a code is worth, without changing anything. Use it to show the balance before taking a payment.
| Field | Type | What it is |
|---|---|---|
| code | string, required | The code as the customer typed it. Dashes, spaces and lower case are fine. |
curl -X POST https://cardyay.com/api/v1/cards/lookup \
-H "Authorization: Bearer cyk_your_key" \
-H "Content-Type: application/json" \
-d '{ "code": "ACDE-FGHJ-KMNP-QRTW" }'{
"card": {
"id": "crd_4k8m2x9q7w3e5r6t1y0p8s2d",
"last4": "QRTW",
"status": "active",
"currency": "USD",
"initialMinor": 5000,
"balanceMinor": 5000,
"expiresAt": "2027-11-12T10:15:00.000Z",
"brand": { "slug": "fernwood", "name": "Fernwood Tea" }
}
}A code that does not exist, and a code of another company’s card, both answer 404 not_found.
POST/api/v1/cards/redeem
Take value off a card: all of it, or part of it if your card allows spending in parts.
| Field | Type | What it is |
|---|---|---|
| code | string, required | The card code. |
| amount | integer, optional | How much to take, in minor units. Leave it out to take the whole balance. |
| reference | string, optional | Your own note, up to 200 characters: a receipt or order number. It is stored with the redemption and sent in the webhook. |
Send an Idempotency-Key header (any text of up to 100 characters that is unique to this attempt to charge the card) and it becomes safe to retry: if the first call went through, the retry returns the same redemption with "replayed": true and takes nothing more off the card. Use it whenever a timeout could leave you unsure.
curl -X POST https://cardyay.com/api/v1/cards/redeem \
-H "Authorization: Bearer cyk_your_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pos-1042-attempt-1" \
-d '{ "code": "ACDE-FGHJ-KMNP-QRTW", "amount": 2000, "reference": "POS-1042" }'{
"card": {
"id": "crd_4k8m2x9q7w3e5r6t1y0p8s2d",
"last4": "QRTW",
"status": "active",
"currency": "USD",
"initialMinor": 5000,
"balanceMinor": 3000,
"expiresAt": "2027-11-12T10:15:00.000Z",
"brand": { "slug": "fernwood", "name": "Fernwood Tea" }
},
"redemption": { "id": "evt_9h3n5v7b2c4x6z8m1k0j3g5f", "amount": 2000 },
"replayed": false
}Keep redemption.id: you need it to undo the redemption. Possible refusals: 404 not_found, 409 insufficient (the balance is smaller than the amount), 409 inactive (used in full or cancelled), 409 expired, 409 partial_not_allowed (the card must be used in one go) and 409 conflict (the idempotency key was already used for a different card).
POST/api/v1/redemptions/{id}/reverse
Put a redemption's value back on its card, for a cancelled order or a mistake. Each redemption can be undone once.
curl -X POST https://cardyay.com/api/v1/redemptions/evt_9h3n5v7b2c4x6z8m1k0j3g5f/reverse \
-H "Authorization: Bearer cyk_your_key"{
"card": {
"id": "crd_4k8m2x9q7w3e5r6t1y0p8s2d",
"last4": "QRTW",
"status": "active",
"currency": "USD",
"initialMinor": 5000,
"balanceMinor": 5000,
"expiresAt": "2027-11-12T10:15:00.000Z",
"brand": { "slug": "fernwood", "name": "Fernwood Tea" }
}
}There is no body. An unknown id answers 404 not_found; a redemption that was already undone, or a card that has since been cancelled, answers 409.
GET/api/v1/cards
The cards that have been issued, newest first.
| Field | Type | What it is |
|---|---|---|
| card | string, optional | Only cards of this link name, such as fernwood. |
| status | string, optional | active, redeemed, expired or void. |
| limit | integer, optional | How many to return, 1 to 100. The default is 50. |
| after | string, optional | The nextCursor of the previous page. |
curl "https://cardyay.com/api/v1/cards?card=fernwood&status=active&limit=2" \
-H "Authorization: Bearer cyk_your_key"{
"data": [
{
"id": "crd_4k8m2x9q7w3e5r6t1y0p8s2d",
"last4": "QRTW",
"status": "active",
"currency": "USD",
"initialMinor": 5000,
"balanceMinor": 3000,
"expiresAt": "2027-11-12T10:15:00.000Z",
"brand": { "slug": "fernwood", "name": "Fernwood Tea" },
"createdAt": "2026-11-12T10:15:00.000Z",
"order": { "number": "CY-7F3K-9QAM", "reference": null }
},
{
"id": "crd_2a4c6e8g0j2m4p6r8t0w2y4b",
"last4": "H7XA",
"status": "active",
"currency": "USD",
"initialMinor": 2500,
"balanceMinor": 2500,
"expiresAt": null,
"brand": { "slug": "fernwood", "name": "Fernwood Tea" },
"createdAt": "2026-11-11T16:02:41.000Z",
"order": { "number": "CY-AC4D-EF7G", "reference": "customer-88213" }
}
],
"hasMore": true,
"nextCursor": "crd_2a4c6e8g0j2m4p6r8t0w2y4b"
}While hasMore is true, ask again with after set to nextCursor. Each item is the card object plus when it was issued and the order it came from, with the reference you or the buyer supplied.
POST/api/v1/requests
A link for one customer to pay one amount for your card.
| Field | Type | What it is |
|---|---|---|
| card | string, required | The link name of the card to sell. It must be on sale. |
| string, required | The customer’s e-mail address. It is filled in for them on the payment page, and their receipt goes there. | |
| amount | integer, required | What to charge, in minor units. It must be a value your card offers: one of its listed values, or within its range. |
| name | string, optional | The customer’s name, up to 80 characters. |
| message | string, optional | Shown to the customer on the payment page, up to 500 characters. |
| reference | string, optional | Your own id for this payment, up to 120 characters. It comes back in the webhook and on the order. |
| returnUrl | string, optional | Where the customer goes after paying. It must be https and on your business website: the same host, or a subdomain of it. |
| sendEmail | boolean, optional | Set to true to have us e-mail the link to the customer as well. Off unless you ask: usually you send them to the link yourself. |
curl -X POST https://cardyay.com/api/v1/requests \
-H "Authorization: Bearer cyk_your_key" \
-H "Content-Type: application/json" \
-d '{
"card": "kestrel-play",
"email": "sam@example.com",
"name": "Sam",
"amount": 2000,
"reference": "cart-58213",
"message": "Top-up for player 88213",
"returnUrl": "https://kestrelplay.example/checkout/58213/done"
}'The answer is 201 with the link:
{
"id": "req_2b4d6f8h0j1k3m5n7p9q2r4s",
"url": "https://cardyay.com/r/req_2b4d6f8h0j1k3m5n7p9q2r4s?k=5f0c…",
"expiresAt": "2026-11-26T10:15:00.000Z"
}Refusals: 404 card_not_found, 409 card_not_on_sale, 400 invalid_amount, 400 invalid_return_url, and 429 daily_limit when the day’s allowance is used up (we e-mail at most 50 requests a day for one business). The url carries its own key: give it only to the customer it is for.
GET/api/v1/requests/{id}
Where a pay request stands and, once it is paid, the order it became.
curl https://cardyay.com/api/v1/requests/req_2b4d6f8h0j1k3m5n7p9q2r4s \
-H "Authorization: Bearer cyk_your_key"{
"id": "req_2b4d6f8h0j1k3m5n7p9q2r4s",
"status": "paid",
"card": "kestrel-play",
"amount": 2000,
"currency": "USD",
"email": "sam@example.com",
"reference": "cart-58213",
"returnUrl": "https://kestrelplay.example/checkout/58213/done",
"url": "https://cardyay.com/r/req_2b4d6f8h0j1k3m5n7p9q2r4s?k=5f0c…",
"createdAt": "2026-11-12T10:15:00.000Z",
"expiresAt": "2026-11-26T10:15:00.000Z",
"paidAt": "2026-11-12T10:19:27.000Z",
"orderNumber": "CY-7F3K-9QAM"
}status is sent (created, not opened yet), opened (the customer has seen the page), paid, cancelled or expired. paidAt and orderNumber are null until it is paid.
Webhooks
A webhook is a POST we make to your server when something happens to one of your cards. Set the endpoint for each card in your dashboard under Developers. It must be a public https:// address. When you save it we create a signing secret (it starts with whsec_) and show it once.
POST /webhooks/cardyay HTTP/1.1
Content-Type: application/json
User-Agent: cardyay-webhooks/1.0
X-Cardyay-Event: card.sold
X-Cardyay-Delivery: whd_7t5r3e1w9q8p6n4m2k0j8h6g
X-Cardyay-Signature: t=1794478767,v1=5d41402abc4b2a76b9719d911017c592…- Answer with any
2xxstatus within 10 seconds to accept an event. Do the slow work after you have answered. - Anything else, a timeout or a redirect counts as a failure. We try up to 8 times over about half an hour, waiting longer each time, then stop and mark the delivery as failed.
- The same event can arrive more than once. Its
id(also inX-Cardyay-Delivery) is the same on every attempt: ignore an id you have already handled. - Events can arrive out of order. The balance in the newest event you have for a card is the one to trust.
- Your dashboard lists recent deliveries with your server’s answers, and has a button that sends a test
card.sold. Test events carry"test": trueat the top level; real ones have no such field.
card.sold
A card was paid for. For a code card the buyer has been e-mailed a code and the card is active with its full balance:
{
"id": "whd_7t5r3e1w9q8p6n4m2k0j8h6g",
"event": "card.sold",
"created": 1794478767,
"data": {
"card": {
"id": "crd_4k8m2x9q7w3e5r6t1y0p8s2d",
"last4": "QRTW",
"amount": 5000,
"currency": "USD",
"balance": 5000,
"status": "active"
},
"mode": "code",
"order": {
"number": "CY-7F3K-9QAM",
"reference": null,
"buyerEmail": "sam@example.com",
"recipientEmail": "alex@example.com",
"requestId": null
}
}
}For a credit card there is no code. The payment is yours to apply now: credit data.card.amount to the account or order named in data.order.reference (what the buyer typed, or the reference of your pay request). The card arrives already redeemed with a balance of 0:
{
"id": "whd_3s5u7w9y1a3c5e7g9j1m3p5r",
"event": "card.sold",
"created": 1794478767,
"data": {
"card": {
"id": "crd_6n8q0s2v4x6z8b0d2f4h6k8m",
"last4": null,
"amount": 2000,
"currency": "USD",
"balance": 0,
"status": "redeemed"
},
"mode": "credit",
"order": {
"number": "CY-AC4D-EF7G",
"reference": "cart-58213",
"buyerEmail": "sam@example.com",
"recipientEmail": "sam@example.com",
"requestId": "req_2b4d6f8h0j1k3m5n7p9q2r4s"
}
}
}| Field | Type | What it is |
|---|---|---|
| data.card.amount | integer | What the card was sold for, in minor units. |
| data.mode | string | code or credit, as set on the card. |
| data.order.number | string | The cardyay order number, as shown to the buyer. |
| data.order.reference | string or null | Your reference, or what the buyer was asked for. |
| data.order.buyerEmail | string | Who paid. |
| data.order.recipientEmail | string | Who received the card: the buyer, or the person it was a gift for. |
| data.order.requestId | string or null | The pay request this sale came from, if any. |
card.redeemed
Value was taken off a card, from your dashboard or through the API. data.card.balance is what is left; status becomes redeemed when it reaches 0.
{
"id": "whd_5v7x9z1b3d5f7h9k1n3q5s7t",
"event": "card.redeemed",
"created": 1794482367,
"data": {
"card": {
"id": "crd_4k8m2x9q7w3e5r6t1y0p8s2d",
"last4": "QRTW",
"currency": "USD",
"balance": 3000,
"status": "active"
},
"redemption": { "id": "evt_9h3n5v7b2c4x6z8m1k0j3g5f", "amount": 2000, "reference": "POS-1042" }
}
}card.reversed
A redemption was undone and its amount is back on the card. data.redemption.id is the redemption that was undone.
{
"id": "whd_8a0c2e4g6j8m0p2r4t6w8y0b",
"event": "card.reversed",
"created": 1794482967,
"data": {
"card": {
"id": "crd_4k8m2x9q7w3e5r6t1y0p8s2d",
"last4": "QRTW",
"currency": "USD",
"balance": 5000,
"status": "active"
},
"redemption": { "id": "evt_9h3n5v7b2c4x6z8m1k0j3g5f", "amount": 2000 }
}
}card.voided
A card was cancelled because its order was refunded. It can no longer be redeemed. If you had already credited something for it, take that back.
{
"id": "whd_1d3f5h7k9n1q3s5v7x9z1b3d",
"event": "card.voided",
"created": 1794565167,
"data": {
"card": {
"id": "crd_4k8m2x9q7w3e5r6t1y0p8s2d",
"last4": "QRTW",
"amount": 5000,
"currency": "USD"
},
"order": { "number": "CY-7F3K-9QAM", "reference": null }
}
}Verify the signature
Anyone can POST to a public address, so check that an event really came from us before acting on it. Every delivery carries an X-Cardyay-Signature header:
X-Cardyay-Signature: t=<unix>,v1=<hex hmac-sha256 of "<t>.<raw body>">- Split the header on commas to get
t(when we signed it, in Unix seconds) andv1(the signature). - Refuse the event if
tis more than 5 minutes away from your server’s clock. That stops an old, captured request from being replayed. - Compute HMAC-SHA256 of the text
<t>.<raw body>with your signing secret as the key, in lowercase hex. Use the body exactly as it arrived, before any JSON parsing. - Compare your result with
v1using a constant-time comparison. If they differ, answer400and do nothing.
In Node.js, with Express:
import crypto from 'node:crypto'
import express from 'express'
const SECRET = process.env.CARDYAY_WEBHOOK_SECRET // whsec_...
const TOLERANCE_SECONDS = 300
function verify(rawBody, header) {
// Header: t=<unix seconds>,v1=<hex hmac-sha256 of "<t>.<raw body>">
const parts = Object.fromEntries(
String(header ?? '').split(',').map((part) => part.trim().split('='))
)
const timestamp = Number(parts.t)
if (!Number.isFinite(timestamp) || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(parts.v1)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
const app = express()
// Keep the body exactly as it was sent: the signature covers the raw bytes.
app.post('/webhooks/cardyay', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8')
if (!verify(rawBody, req.get('X-Cardyay-Signature'))) return res.sendStatus(400)
const event = JSON.parse(rawBody)
if (event.test) return res.sendStatus(200) // sent from "Send a test event"
// event.id is the same on every retry: skip an id you have already handled.
if (event.event === 'card.sold') {
// credit the account in event.data.order.reference with event.data.card.amount
}
res.sendStatus(200)
})Rotating the secret in your dashboard replaces it at once: deploy the new secret to your server straight after you rotate, or events signed in between will fail your check and be retried.
Errors
The HTTP status tells you the kind of problem; error.code tells you which one. Codes do not change; messages may be reworded.
| Status | Code | What happened |
|---|---|---|
| 400 | invalid_request | A field is missing or has the wrong type. The message names the field. |
| 400 | invalid_json | The body is not a JSON object. |
| 400 | invalid | The amount to redeem is not a whole number above zero. |
| 400 | invalid_amount | The amount is not a value this card offers, or is over the limit for one purchase. |
| 400 | invalid_return_url | returnUrl is not an https address on your business website. |
| 400 | invalid_idempotency_key | The Idempotency-Key header is empty, too long or contains spaces. |
| 400 | invalid_cursor | The after value is not a cursor from a previous page. |
| 401 | unauthorized | The key is missing, wrong or revoked. |
| 403 | business_not_approved | The business is waiting for review or is not active. |
| 404 | not_found | No card, redemption, pay request or endpoint matches. |
| 404 | card_not_found | You have no card with that link name. |
| 405 | method_not_allowed | The endpoint exists but not for this method. |
| 409 | insufficient | The card’s balance is smaller than the amount. |
| 409 | inactive | The card is used in full or was cancelled. |
| 409 | expired | The card has expired. |
| 409 | partial_not_allowed | This card must be used in full in one go. |
| 409 | conflict | The redemption was already undone, or the idempotency key was used for a different request. |
| 409 | card_not_on_sale | Pay requests can only be created for a card that is on sale. |
| 413 | body_too_large | The request body is over 16 KB. |
| 415 | unsupported_media_type | The body was not sent as application/json. |
| 429 | rate_limited | Too many requests. Wait for the number of seconds in Retry-After. |
| 429 | too_many_misses | Too many codes that match no card. Wait ten minutes. |
| 429 | daily_limit | The day’s allowance of pay requests was reached. |
| 500 | internal_error | Something failed on our side. Retry; redemptions with an Idempotency-Key are safe to repeat. |
Something here does not match what you see? Contact us through the help centre and include the X-Cardyay-Delivery id or the time of the call.