Webhooks
Event types, payloads, signatures, and retries.
Webhooks tell your server when something changes, so you do not have to poll: deposits, operations, and shipments.
Add an endpoint
In the console, open Webhooks and add an endpoint URL. Choose All events, or pick specific event types. Copy the signing secret: it is shown only once.
You can also manage endpoints with the API:
curl -X POST "$PACKFLIP_BASE_URL/api/v2/webhooks" \
-H "Authorization: Bearer $PACKFLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/webhooks/packflip", "subscribedEvents": ["*"] }'
The response includes signingSecret once. Endpoint URLs must be public HTTPS URLs on port 443. Sandbox and production endpoints are configured separately.
| Action | API |
|---|---|
| List endpoints | GET /api/v2/webhooks |
| Change URL or events | PATCH /api/v2/webhooks/{id} with url or subscribedEvents |
| Disable or enable | PATCH with active, or DELETE to disable |
| Rotate the secret | PATCH with "rotateSigningSecret": true; the response has the new secret |
| Delivery history | GET /api/v2/webhooks/{id}/deliveries |
| Resend an event | POST /api/v2/webhooks/{id}/replay with { "eventId": "evt_…" } |
Events
| Type | When |
|---|---|
balance.topup.detected | A USDC deposit reached your address and is being verified. |
balance.topup.confirmed | A deposit was verified and credited. |
balance.topup.failed | A detected deposit failed verification. |
operation.completed | An order or mint finished. |
operation.awaiting_chain | Mint or on-chain buyback authorizations are ready to submit. |
operation.onchain_failed | A reported mint transaction failed or did not match. |
operation.onchain_cancelled | An expired mint authorization was cancelled. |
operation.cancelled | An on-chain operation ended with nothing confirmed. |
buyback.completed | A buyback finished and its credit was posted. |
refund.completed | Sealed cards were refunded. |
redemption.completed | Cards were redeemed for shipping. |
shipment.updated | Packflip changed a shipment's status or tracking. |
Subscribing to * also delivers event types added in the future. Ignore types you do not handle.
Payload
Every delivery is a POST with a JSON body:
{
"id": "evt_…",
"type": "balance.topup.confirmed",
"apiVersion": "2026-09-12",
"environment": "production",
"createdAt": "2026-09-17T08:00:00.000Z",
"partnerId": "par_…",
"data": {
"fundingTransactionId": "ftx_…",
"amountUsd": "500.000000",
"chainId": 8453,
"transactionHash": "0x…"
}
}
data depends on the type and always carries the IDs you need to fetch the full object, such as operationId and customerId. Treat events as notifications: when in doubt, fetch the current state from the API.
shipment.updated
Sent whenever Packflip changes a redemption's shipment: its status, carrier, tracking, or note. Re-recording the same values sends nothing.
{
"type": "shipment.updated",
"data": {
"operationId": "op_…",
"customerId": "cus_…",
"previousStatus": "created",
"changedFields": ["status", "carrier", "trackingNumber", "trackingUrl"],
"shipment": {
"id": "shp_…",
"status": "shipped",
"carrier": "Yamato",
"trackingNumber": "1234-5678-9012",
"trackingUrl": "https://…",
"statusReason": null,
"updatedAt": "2026-09-18T02:00:00.000Z"
}
}
}
statusmoves throughcreated,shipped,in_transit, anddelivered.exceptionmeans the shipment is on hold;statusReasonexplains why. For an address problem, correct it withPUT /api/v2/operations/{operationId}/shipment/address(see Cards); the shipment returns tocreated.changedFieldscan includeshipmentInfowhen the address was corrected. The event never repeats the address; fetch the operation to read it.- Use this event to notify your customer in your own product. Packflip emails them only when the redemption asked for
customer_email(see Cards). - In the sandbox, move a shipment along yourself with
PUT /api/v2/operations/{operationId}/shipmentto test your handler.
Verify the signature
Each delivery has three headers:
webhook-id: the event IDwebhook-timestamp: Unix time in secondswebhook-signature:v1,followed by the hex HMAC-SHA256 of{webhook-timestamp}.{raw body}, keyed with the endpoint's signing secret
Verify against the raw request body, before parsing JSON, and reject old timestamps to prevent replays.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyPackflipWebhook(rawBody: string, headers: Headers, secret: string) {
const timestamp = headers.get("webhook-timestamp") ?? "";
const signature = headers.get("webhook-signature") ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = `v1,${createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")}`;
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
// Next.js route handler
export async function POST(request: Request) {
const rawBody = await request.text();
if (!verifyPackflipWebhook(rawBody, request.headers, process.env.PACKFLIP_WEBHOOK_SECRET!)) {
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody);
// Deduplicate on event.id, then handle event.type.
return new Response(null, { status: 204 });
}
Delivery and retries
- Respond with any
2xxstatus within 10 seconds. Do slow work after responding. - Other responses, timeouts, and connection errors are retried with exponential backoff (2, 4, 8, … minutes, at most an hour apart), up to 8 attempts. After that the delivery is marked
failed; replay it once your endpoint is fixed. - Deliveries can arrive more than once and out of order. Deduplicate on
idand do not assume ordering. - Disabling an endpoint pauses its deliveries. Events are matched to endpoints when they happen, so changing subscriptions does not add deliveries for past events.
The console's Webhooks page shows recent deliveries, their status, and the last response code.