Skip to content
cardyay

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

  1. Apply to sell and wait for your business to be approved. The API answers 403 until then.
  2. In your dashboard, open Developers and create an API key. The account owner does this. The key is shown once.
  3. 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 answers 403 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. 2500 is $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 id and the last four characters of the code.
  • Each key may make 120 requests a minute. Past that the API answers 429 rate_limited with a Retry-After header in seconds.
  • Codes that match no card are counted: after 60 in ten minutes, lookups and redemptions with that key answer 429 too_many_misses until 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" }
}
FieldTypeWhat it is
idstringThe card. Safe to store and to show.
last4string or nullLast four characters of the code. Null for a card with no code (see "credit" below).
statusstringactive can be spent. redeemed is used in full. expired passed its date. void was cancelled, for example after a refund.
currencystringISO 4217 code.
initialMinorintegerWhat the card was sold for.
balanceMinorintegerWhat is left on it.
expiresAtstring or nullWhen it stops working. Null for a card that never expires.
brandobjectWhich of your cards it is: slug (its link name) and name.

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.

FieldTypeWhat it is
codestring, requiredThe 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.

FieldTypeWhat it is
codestring, requiredThe card code.
amountinteger, optionalHow much to take, in minor units. Leave it out to take the whole balance.
referencestring, optionalYour 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.

FieldTypeWhat it is
cardstring, optionalOnly cards of this link name, such as fernwood.
statusstring, optionalactive, redeemed, expired or void.
limitinteger, optionalHow many to return, 1 to 100. The default is 50.
afterstring, optionalThe 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.

FieldTypeWhat it is
cardstring, requiredThe link name of the card to sell. It must be on sale.
emailstring, requiredThe customer’s e-mail address. It is filled in for them on the payment page, and their receipt goes there.
amountinteger, requiredWhat to charge, in minor units. It must be a value your card offers: one of its listed values, or within its range.
namestring, optionalThe customer’s name, up to 80 characters.
messagestring, optionalShown to the customer on the payment page, up to 500 characters.
referencestring, optionalYour own id for this payment, up to 120 characters. It comes back in the webhook and on the order.
returnUrlstring, optionalWhere the customer goes after paying. It must be https and on your business website: the same host, or a subdomain of it.
sendEmailboolean, optionalSet 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 2xx status 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 in X-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": true at 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"
    }
  }
}
FieldTypeWhat it is
data.card.amountintegerWhat the card was sold for, in minor units.
data.modestringcode or credit, as set on the card.
data.order.numberstringThe cardyay order number, as shown to the buyer.
data.order.referencestring or nullYour reference, or what the buyer was asked for.
data.order.buyerEmailstringWho paid.
data.order.recipientEmailstringWho received the card: the buyer, or the person it was a gift for.
data.order.requestIdstring or nullThe 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>">
  1. Split the header on commas to get t (when we signed it, in Unix seconds) and v1 (the signature).
  2. Refuse the event if t is more than 5 minutes away from your server’s clock. That stops an old, captured request from being replayed.
  3. 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.
  4. Compare your result with v1 using a constant-time comparison. If they differ, answer 400 and 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.

StatusCodeWhat happened
400invalid_requestA field is missing or has the wrong type. The message names the field.
400invalid_jsonThe body is not a JSON object.
400invalidThe amount to redeem is not a whole number above zero.
400invalid_amountThe amount is not a value this card offers, or is over the limit for one purchase.
400invalid_return_urlreturnUrl is not an https address on your business website.
400invalid_idempotency_keyThe Idempotency-Key header is empty, too long or contains spaces.
400invalid_cursorThe after value is not a cursor from a previous page.
401unauthorizedThe key is missing, wrong or revoked.
403business_not_approvedThe business is waiting for review or is not active.
404not_foundNo card, redemption, pay request or endpoint matches.
404card_not_foundYou have no card with that link name.
405method_not_allowedThe endpoint exists but not for this method.
409insufficientThe card’s balance is smaller than the amount.
409inactiveThe card is used in full or was cancelled.
409expiredThe card has expired.
409partial_not_allowedThis card must be used in full in one go.
409conflictThe redemption was already undone, or the idempotency key was used for a different request.
409card_not_on_salePay requests can only be created for a card that is on sale.
413body_too_largeThe request body is over 16 KB.
415unsupported_media_typeThe body was not sent as application/json.
429rate_limitedToo many requests. Wait for the number of seconds in Retry-After.
429too_many_missesToo many codes that match no card. Wait ten minutes.
429daily_limitThe day’s allowance of pay requests was reached.
500internal_errorSomething 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.