Developers
Webhooks
Cardigan can send a signed HTTPS POST to your system whenever a gift card it issued or redeemed changes status. The events and payloads match Cardigan's Shopify Flow triggers. Webhooks carry the full card number and PIN, so card details reach your system without passing through Flow or its run history.
Setting up an endpoint
Webhook configuration is not yet in Cardigan's admin, so Cardigan support sets up endpoints for now. To add one, the merchant emails help@runcardigan.com with your endpoint URL and the event types you need. Support then shares the signing secret Cardigan generates for the endpoint.
The endpoint must use HTTPS. Verify the signature on every request before you trust or store the body, as described in Verifying a webhook.
Events
Cardigan sends two event types. Each fires only when the card moves into one of the statuses listed, the same set that fires the matching Flow trigger.
| Event type | Sent when the status becomes | Flow trigger with the same payload |
|---|---|---|
issued_card.status_changed | provisioned, scheduled, failed, cancelled | Issued card status changed |
redeemed_card.status_changed | redeemed, failed, cancelled | Redeemed card status changed |
An issued card is a gift card Cardigan created with the merchant's provider, usually for a gift card product on an order. A redeemed card is a charge against a provider card when a customer pays with it at checkout or on POS.
Payload
Every body is a JSON object with the same four top-level keys. data holds exactly what the matching Flow trigger receives.
| Key | Type | Meaning |
|---|---|---|
type | string | The event type, e.g. issued_card.status_changed |
timestamp | string | When the status changed, ISO 8601 in UTC |
shop | string | The merchant's myshopify.com domain |
data | object | The card and its status change, keyed issuedCard or redeemedCard, plus statusChange |
Keys are camelCase. Fields with no value are present as null. Inside metadata, a key appears only when the order supplied it.
Each timestamp field is an object with iso8601 (UTC) and formatted (in the shop's timezone and date format, for display). Each money field has cents, currencyCode and formatted. Parse iso8601 and cents, because the formatted values change with the merchant's settings.
Issued card status changed
On an issued card, the top-level number, pin and providerId are masked. The full values are in providerDetails.
{
"type": "issued_card.status_changed",
"timestamp": "2026-10-24T01:15:43Z",
"shop": "example-store.myshopify.com",
"data": {
"issuedCard": {
"id": "0199f8a2-3c41-7d52-b8e3-2a6f1c9d4e07",
"amount": { "cents": 5000, "currencyCode": "USD", "formatted": "$50 USD" },
"cancelledAt": null,
"cardType": "digital",
"createdAt": { "iso8601": "2026-10-24T01:15:40Z", "formatted": "Oct 23 at 9:15 pm EDT" },
"failures": [],
"lastFailedAt": null,
"metadata": {
"customerName": "Sam Lee",
"customerEmail": "sam@example.com",
"greeting": "Happy birthday!",
"recipientName": "Alex Lee",
"recipientEmail": "alex@example.com",
"senderName": "Sam"
},
"number": "••••••••••••3456",
"pin": "••••",
"profile": {
"id": "0198c1d0-5e2a-7f13-a4b6-8d9e0f1a2b3c",
"name": "Givex",
"provider": "givex",
"status": "default"
},
"providerDetails": {
"id": "1029384756",
"number": "6034123412343456",
"pin": "1234",
"url": null
},
"providerId": "••••••4756",
"providerUrl": null,
"provisionedAt": { "iso8601": "2026-10-24T01:15:43Z", "formatted": "Oct 23 at 9:15 pm EDT" },
"scheduledAt": null,
"shopifyOrderId": 6123456789012,
"shopifyTransactionId": null,
"source": {
"name": "#1042",
"type": "order",
"url": "https://admin.shopify.com/store/example-store/orders/6123456789012"
},
"status": "provisioned",
"url": "https://admin.shopify.com/store/example-store/apps/runcardigan/issued_cards/0199f8a2-3c41-7d52-b8e3-2a6f1c9d4e07"
},
"statusChange": {
"previousStatus": "provisioning",
"status": "provisioned"
}
}
}
cardType is digital or physical. source.type is order, user, refund or api. When the status is failed, failures lists each attempt's error as an object with code, description and message.
Redeemed card status changed
On a redeemed card, card.number and card.pin are the full values.
{
"type": "redeemed_card.status_changed",
"timestamp": "2026-10-24T02:04:11Z",
"shop": "example-store.myshopify.com",
"data": {
"redeemedCard": {
"id": "0199f8cf-91a0-7b44-8c2d-5e6f7a8b9c0d",
"amount": { "cents": 2500, "currencyCode": "USD", "formatted": "$25 USD" },
"cancelledAt": null,
"card": {
"id": "0199f8ce-02b7-7a61-9d3e-4f5a6b7c8d9e",
"currencyCode": "USD",
"number": "6034123412349876",
"pin": "5678",
"profile": {
"id": "0198c1d0-5e2a-7f13-a4b6-8d9e0f1a2b3c",
"name": "Givex",
"provider": "givex",
"status": "default"
},
"shopifyId": 612345678901
},
"createdAt": { "iso8601": "2026-10-24T02:04:02Z", "formatted": "Oct 23 at 10:04 pm EDT" },
"failures": [],
"lastFailedAt": null,
"redeemedAt": { "iso8601": "2026-10-24T02:04:11Z", "formatted": "Oct 23 at 10:04 pm EDT" },
"shopifyOrderId": 6123456799999,
"shopifyOrderName": "#1043",
"shopifyOrderUrl": "https://admin.shopify.com/store/example-store/orders/6123456799999",
"shopifyPaymentId": "c26934567890123.1",
"shopifyTransactionId": 7123456789012,
"status": "redeemed",
"url": "https://admin.shopify.com/store/example-store/apps/runcardigan/redeemed_cards/0199f8cf-91a0-7b44-8c2d-5e6f7a8b9c0d"
},
"statusChange": {
"previousStatus": "redeeming",
"status": "redeemed"
}
}
}
Request headers
Cardigan follows the Standard Webhooks specification, so the three webhook-* headers mean what that specification says they mean.
| Header | Example | Meaning |
|---|---|---|
webhook-id | 0199f8a2-3c4f-7e10-9b2a-6c7d8e9f0a1b | Unique per event and unchanged across retries. Use it to discard duplicates. |
webhook-timestamp | 1792804543 | Unix seconds when this attempt was signed. Each retry carries a new value. |
webhook-signature | v1,+OycX+z1FkdB4DU9hBbMW5EScNr4bEK9RtesitjOMKw= | One or more signatures, separated by spaces. |
content-type | application/json | |
user-agent | Cardigan-Webhooks/1.0 |
Header names are case-insensitive. Your framework may present them as Webhook-Id or HTTP_WEBHOOK_ID.
Verifying a webhook
Recompute the HMAC-SHA256 signature of webhook-id.webhook-timestamp.body with your signing secret and compare it with webhook-signature. Reject the request if no signature matches or the timestamp is more than five minutes from your clock.
- Read the raw request body as bytes, before any JSON parsing. A body that has been parsed and re-serialised will not match.
- Reject the request if
webhook-timestampis more than 300 seconds before or after your current time. - Strip the
whsec_prefix from the secret and base64-decode the rest. The decoded bytes are the HMAC key. - Compute HMAC-SHA256 over
{webhook-id}.{webhook-timestamp}.{raw body}and base64-encode the result. - Split
webhook-signatureon spaces. Accept the request if anyv1,entry equals your result, using a constant-time comparison. - On any failure, respond
401and do not process the body.
When support rotates the secret, Cardigan signs with both the old and the new secret for 24 hours. The header then carries two v1, entries, and step 5 accepts either.
With a library
The Standard Webhooks libraries cover JavaScript, Python, Ruby, PHP, Go, Java, C# and others. In Node:
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.CARDIGAN_WEBHOOK_SECRET);
// Throws WebhookVerificationError on a bad signature or stale timestamp.
const event = webhook.verify(rawBody, request.headers);
The constructor accepts the secret with its whsec_ prefix.
By hand in Node
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyCardiganWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) {
throw new Error("Missing webhook headers");
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
throw new Error("Timestamp outside tolerance");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
const matched = signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const received = Buffer.from(signature, "base64");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
if (!matched) {
throw new Error("No matching signature");
}
return JSON.parse(rawBody);
}
By hand in Python
import base64
import hashlib
import hmac
import json
import time
TOLERANCE_SECONDS = 5 * 60
def verify_cardigan_webhook(raw_body: bytes, headers, secret: str):
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signatures = headers["webhook-signature"]
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
raise ValueError("Timestamp outside tolerance")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return json.loads(raw_body)
raise ValueError("No matching signature")
Test vector
Check your implementation against these values with the clock check pinned to 1792804543. The body is the exact bytes shown, with no trailing newline.
| Input | Value |
|---|---|
| Secret | whsec_Y2FyZGlnYW4td2ViaG9vay10ZXN0LXNlY3JldC0zMmI= |
webhook-id | 0199f8a2-3c4f-7e10-9b2a-6c7d8e9f0a1b |
webhook-timestamp | 1792804543 |
Expected webhook-signature | v1,+OycX+z1FkdB4DU9hBbMW5EScNr4bEK9RtesitjOMKw= |
{"type":"issued_card.status_changed","timestamp":"2026-10-24T01:15:43Z","shop":"example-store.myshopify.com","data":{}}
Responding and retries
Respond with any 2xx status within 15 seconds. Queue the work and acknowledge first, because a slow handler counts as a failed delivery.
| Your response | What Cardigan does |
|---|---|
2xx | Marks the delivery sent |
3xx | Treats it as a failure and does not follow the redirect |
410 Gone | Disables the endpoint and stops sending to it, until support re-enables it |
| Any other status, a timeout or a connection error | Retries |
Cardigan retries a failed delivery after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours. Each retry is signed afresh, so webhook-timestamp is always current. If the last attempt fails, Cardigan marks the delivery failed and stops retrying it.
You may receive the same event more than once, for example when your 2xx is lost in transit. Record each webhook-id you process and skip any you have seen.
Delivery order is not guaranteed. A retried event can arrive after a later one for the same card. Use the card's id to identify it and the body's timestamp to keep the most recent status.
Handling card data
A card number and PIN together are enough to spend the card's balance. Treat every webhook body as a credential.
- Verify the signature before you parse, store or forward anything.
- Keep raw bodies out of request logs, error trackers and unencrypted queues. Many frameworks log request parameters by default, so filter
providerDetails,card.numberandcard.pin. - Encrypt card numbers and PINs at rest, or drop them once you have passed them on.
- If the signing secret leaks, ask the merchant to have Cardigan support rotate it.