CoinPay

Webhooks

Receive signed callbacks for invoice status changes and verify them safely.

CoinPay sends an HTTP POST to your webhook URL when an invoice changes state. Configure one endpoint (and secret) per environment — test and live are separate.

Configure

Configure webhook URL and signing secret in the merchant dashboard (Webhooks).
API keys cannot create, update, or inspect webhook configuration.

EnvironmentURL rules
testhttp or https; localhost / tunnels allowed
livehttps only; private and metadata hosts blocked

Secret is required on first create (16–255 characters). Omit secret on later updates to keep the existing one.

Optional customHeaders are appended to every delivery. You cannot override Content-Type or X-Coinpay-Signature.

Webhook configure/list/resend endpoints are not part of the public merchant API reference — use the dashboard.

Events

EventWhenMerchant action
invoice.createdInvoice persistedOptional logging
invoice.payment_detectedTransfer seen, not finalWait
invoice.underpaidPartial paymentWait for top-up (30m)
invoice.paidFinality within 1h windowFulfill / ship
invoice.late_paidFinality after 1h windowFunds received — do not auto-fulfill
invoice.overpaidAmount above requiredOps / support (no auto-sweep in Phase 1)
invoice.expiredNo payment in 1hClose order
invoice.expired_underpaidTop-up window ended shortClose / contact customer
invoice.settledSweep completeAccounting confirmation
invoice.payment_invalidatedReorg removed a paymentReconcile carefully

Fulfillment rule: treat only invoice.paid as safe to ship. Use invoice.settled for balance/accounting. Never treat invoice.late_paid as a normal fulfillment signal.

Payload

{
  "id": "<invoiceId>-<statusSequence>",
  "type": "invoice.paid",
  "createdAt": "2026-07-04T12:00:00Z",
  "environment": "test",
  "data": {
    "invoiceId": "uuid",
    "orderId": "uuid",
    "status": "PAID",
    "merchantId": "uuid",
    "referenceId": "merchant-ref-optional",
    "metadata": {},
    "amount": "10",
    "amountReceived": "10",
    "token": "USDC",
    "chainId": "sepolia",
    "sequence": 3
  }
}

invoiceId and orderId are the same UUID (orderId kept for older integrations). amount and amountReceived are decimal strings in major units ("10" = 10.00 USDC/USDT).

Idempotency: process on envelope id. Resends keep the same id and create a new delivery row.

Verify signatures

Header: X-Coinpay-Signature: t=<unix_seconds>,v1=<hmac_hex>

HMAC-SHA256 over {timestamp}.{rawBody} with your webhook secret.

  1. Parse t and v1
  2. Reject if |now - t| > 300 seconds
  3. Recompute HMAC and compare with constant-time equality
  4. Always use the raw request body (not a re-serialized JSON object)

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyCoinpaySignature(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=")),
  );
  const timestamp = parts.t;
  const signature = parts.v1;
  if (!timestamp || !signature) return false;

  const ageSec = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(ageSec) || ageSec > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  try {
    return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  } catch {
    return false;
  }
}

Respond with 2xx quickly after verifying. Heavy work should be async on your side.

Delivery and retries

  • Success: HTTP 2xx within ~10 seconds (redirects are not followed)
  • Backoff: 1m → 5m → 30m → 2h → 24h (6 attempts)
  • Inspect and resend deliveries from the merchant dashboard (Webhooks)

Test vs live

Test API keys only create and read test invoices; deliveries go to the test webhook. Live is a separate URL + secret after go-live approval.

See also: Invoice lifecycle · Errors

On this page