← Back to API Docs

Webhooks

Webhooks

Configure your endpoint, verify HMAC signatures, and handle Doggie Pay events idempotently.

Webhooks

Webhooks notify your server when on-chain and ledger events complete. Prefer them over browser redirects or client-side polls when deciding to mark an order paid or ship.

Related: Getting started · Accept a payment

1. Configure the endpoint

In merchant.doggiepay.com:

  1. Set your HTTPS Webhook URL (must accept POST with raw JSON body)
  2. Save the webhook_secret shown once (whsec_…)
  3. You can rotate the secret later; old signatures stop validating after rotate
Portal / APIDescription
PUT /v1/merchant/webhookSet { "url": "https://…" } → returns plaintext webhook_secret once
POST /v1/merchant/webhook/rotate-secretNew secret (plaintext once)
DELETE /v1/merchant/webhookClear URL + secret
GET /v1/merchant/webhook/eventsDelivery log (portal / Secret)

2. Delivery contract

ItemValue
MethodPOST
BodyRaw JSON event envelope
SuccessRespond 2xx quickly (ack first, process async if needed)
FailureNon-2xx → retried with backoff via outbox drain
HeaderX-DoggiePay-Signature: t=<unix>,v1=<hex>
Content-Typeapplication/json

Envelope

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "checkout.paid",
  "data": { }
}
FieldTypeDescription
iduuidOutbox / event id — use for idempotency
typestringEvent name
dataobjectEvent payload

3. Verify the signature

Header format:

X-DoggiePay-Signature: t=1710000000,v1=abcdef…

Algorithm:

v1 = HMAC-SHA256( webhook_secret,  t + "." + rawBody )

Compare v1 to the hex digest using a constant-time compare. Reject if:

  • Header missing / malformed
  • |now - t| exceeds your tolerance (e.g. 5 minutes) — replay protection
  • Digest mismatch

Node.js example

import crypto from "node:crypto";

function verifyDoggiePaySignature(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    String(header || "")
      .split(",")
      .map((p) => p.trim().split("="))
      .filter((kv) => kv.length === 2),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;
  const ts = Number(t);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;

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

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

// Express: use express.raw({ type: "application/json" }) for this route
app.post("/webhooks/doggiepay", (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyDoggiePaySignature(raw, req.get("X-DoggiePay-Signature"), process.env.DOGGIEPAY_WEBHOOK_SECRET)) {
    return res.status(401).send("invalid signature");
  }
  const event = JSON.parse(raw);
  // TODO: idempotent handle by event.id
  res.status(200).json({ received: true });
});

Go example

mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "." + string(rawBody)))
expected := hex.EncodeToString(mac.Sum(nil))
ok := hmac.Equal([]byte(expected), []byte(v1))

Always hash the raw request bytes. Do not re-serialize JSON before verifying.

4. Event catalog

Checkout

typeWhenTypical data keys
checkout.paidOn-chain payment confirmed for a Checkout Sessionpayment_id, deposit_id, circle_tx_id
checkout.expiredSession expired unpaidpayment_id
checkout.cancelledSession cancelledpayment_id

payment_id equals the Checkout Session id from create.

Customers (deposits & withdrawals)

typeWhenTypical data keys
deposit.creditedLong-lived wallet deposit credited to merchant pooldeposit_id, amount, currency, external_user_id, blockchain, tx_hash, circle_tx_id, …
withdrawal.preparedPortal prepare flow onlywithdrawal fields
withdrawal.requestedWithdrawal accepted…
withdrawal.submittedSubmitted on-chain / to rail…
withdrawal.completeSuccess…
withdrawal.failedFailed…

external_user_id is your Customer ref_id. Credit your user ledger on deposit.credited; Doggie Pay does not store end-user balances.

Merchant payouts & Checkout refunds

typeRail
payout.requested / submitted / complete / failedMerchant self-custody payout
refund.requested / submitted / complete / failedPayment (Checkout) refund

5. Handler best practices

  1. Verify signature before parsing business logic
  2. Idempotent on event.id (and often payment_id / deposit_id) — deliveries may retry
  3. Return 2xx only after you have durably recorded the event (or queued work)
  4. Keep the HTTP handler fast; do heavy work in a worker
  5. Do not expose webhook_secret to browsers
  6. On secret rotate, deploy the new secret atomically

Minimal checkout.paid flow

verify HMAC
→ if event.id seen → 200
→ mark order paid (payment_id / metadata.order_id)
→ store event.id
→ 200

6. Testing tips

CheckExpected
Wrong signatureYour app returns 401; no side effects
Replay same idSecond handle is no-op
Local tunnelUse HTTPS tunnel to your laptop for the webhook URL
Delivery logInspect GET /v1/merchant/webhook/events in the portal

7. Next steps

  • Complete Accept a payment end-to-end with a real checkout.paid
  • Add Customers deposit handling when you ship in-app wallets