PackflipPartner

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.

ActionAPI
List endpointsGET /api/v2/webhooks
Change URL or eventsPATCH /api/v2/webhooks/{id} with url or subscribedEvents
Disable or enablePATCH with active, or DELETE to disable
Rotate the secretPATCH with "rotateSigningSecret": true; the response has the new secret
Delivery historyGET /api/v2/webhooks/{id}/deliveries
Resend an eventPOST /api/v2/webhooks/{id}/replay with { "eventId": "evt_…" }

Events

TypeWhen
balance.topup.detectedA USDC deposit reached your address and is being verified.
balance.topup.confirmedA deposit was verified and credited.
balance.topup.failedA detected deposit failed verification.
operation.completedAn order or mint finished.
operation.awaiting_chainMint or on-chain buyback authorizations are ready to submit.
operation.onchain_failedA reported mint transaction failed or did not match.
operation.onchain_cancelledAn expired mint authorization was cancelled.
operation.cancelledAn on-chain operation ended with nothing confirmed.
buyback.completedA buyback finished and its credit was posted.
refund.completedSealed cards were refunded.
redemption.completedCards were redeemed for shipping.
shipment.updatedPackflip 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"
    }
  }
}
  • status moves through created, shipped, in_transit, and delivered. exception means the shipment is on hold; statusReason explains why. For an address problem, correct it with PUT /api/v2/operations/{operationId}/shipment/address (see Cards); the shipment returns to created.
  • changedFields can include shipmentInfo when 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}/shipment to test your handler.

Verify the signature

Each delivery has three headers:

  • webhook-id: the event ID
  • webhook-timestamp: Unix time in seconds
  • webhook-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 2xx status 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 id and 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.