~netlandish/ponch-api-docs

#Webhooks

A webhook is a URL you register with Ponch. When one of its events happens, Ponch sends that URL an HTTP POST with the event payload, signed so you can check it came from Ponch.

Connecting Zapier or Make creates and removes webhooks for you. Everything below is for building your own receiver.

#Managing webhooks

#POST /integrations/webhooks

Subscribe a URL to one or more events.

Body field Type
url string Required. An http(s) URL that accepts POST. Use https
events list of strings Required, not empty. Any of the six event types
template_slug string or null Only send events for this template. Omit or null for every template in the company
description string A note for yourself
source string Optional label for the client that created the hook (e.g. zapier, make). Shown in the dashboard; at most 16 characters are kept
{
  "url": "https://hooks.example.com/ponch",
  "events": ["stamp.added", "reward.earned"],
  "template_slug": null,
  "description": "Loyalty sync"
}

Response 201:

{
  "id": "f0e2d4c6-a8b0-4c2e-9a6b-8d0f2a4c6e81",
  "url": "https://hooks.example.com/ponch",
  "events": ["stamp.added", "reward.earned"],
  "template_slug": null,
  "description": "Loyalty sync",
  "source": "",
  "is_active": true,
  "consecutive_failures": 0,
  "last_delivered_at": null,
  "auto_disabled_at": null,
  "created_at": "2026-10-20T00:28:51.231Z",
  "secret": "-l2JqF3xC89GzsYyjcppJuWHpXgXpmkoj6b4pkOSQxs"
}

Save secret now. It's returned only here and by rotate-secret, and you need it to verify signatures.

Error When
400 Unknown template_slug
422 Invalid url, empty events, or an unknown event type
#Other webhook endpoints
Endpoint
GET /integrations/webhooks List the company's webhooks (no secrets)
GET /integrations/webhooks/{webhook_id} One webhook (no secret). 404 if not found
PATCH /integrations/webhooks/{webhook_id} Change any of url, events, template_slug, description, is_active. Fields you leave out are unchanged; "template_slug": null widens the hook to every template. Setting is_active: true also resets the failure count
DELETE /integrations/webhooks/{webhook_id} Unsubscribe. Returns 204. The hook's delivery log is deleted with it
POST /integrations/webhooks/{webhook_id}/rotate-secret New secret. See below
GET /integrations/webhooks/{webhook_id}/deliveries The delivery log. See below
POST /integrations/webhooks/deliveries/{delivery_id}/replay Send one delivery again. See below
#POST /integrations/webhooks/{webhook_id}/rotate-secret
{"id": "f0e2d4c6-a8b0-4c2e-9a6b-8d0f2a4c6e81", "secret": "QgGZ7w_O7hzZZWBtxvd4SVQrVsWnYIuIw-C_6F1eiiA"}

The old secret stops working at once: the next delivery is signed with the new one. To avoid rejecting deliveries in between, have your receiver accept either secret while you switch over.

#GET /integrations/webhooks/{webhook_id}/deliveries

The most recent deliveries, newest first. limit defaults to 50, max 200.

[
  {
    "id": "e261392d-1e81-4f91-821b-08f2771ce67d",
    "event_type": "stamp.added",
    "event_id": "8d59fcdc-63d1-433f-9496-5e291e14cb45",
    "status": "delivered",
    "attempt_count": 1,
    "response_status": 200,
    "response_body": "ok",
    "delivered_at": "2026-10-20T00:28:52.104Z",
    "next_retry_at": null,
    "created_at": "2026-10-20T00:28:51.258Z"
  }
]

status is pending (waiting or between retries), delivered or failed. response_status and response_body are what your server answered on the latest attempt (or the network error, if there was no answer).

#POST /integrations/webhooks/deliveries/{delivery_id}/replay

Queue a delivery to be sent again from scratch, with its original payload. Returns 202 and the delivery, reset to pending. Use it after fixing a receiver that was failing. A replay to a deactivated webhook fails, so re-enable the webhook first.

#Delivery

Each delivery is a POST with a JSON body (the event payload) and these headers:

Header
Content-Type application/json
User-Agent Ponch-Webhook/1.0
X-Ponch-Event The event type, e.g. stamp.added
X-Ponch-Event-Id The payload's event_id
X-Ponch-Webhook-Id The webhook's id
X-Ponch-Signature t=<unix timestamp>,v1=<hex signature>. See below

Respond with any 2xx within 10 seconds. Do slow work after responding, e.g. put the event on a queue first.

What happens with each response:

Your response Ponch does
2xx Marks it delivered
410 Gone Deactivates the webhook straight away and stops sending to it. This is how to unsubscribe from the receiving end. It isn't counted as a failure
408, 429, 5xx, a timeout, or no connection Tries again. Up to 5 attempts in all, waiting 1 min, 5 min, 30 min and 2 h between them
Any other 4xx Marks it failed, no retry: the same request would get the same answer

After 10 failed deliveries in a row (counting a delivery as failed once it's out of attempts), Ponch deactivates the webhook. Any successful delivery resets the count. To turn the webhook back on, click Re-enable under Integrations → Webhooks in the dashboard, or PATCH it with {"is_active": true}.

While a webhook is inactive, events for it are not queued. To catch up on what you missed, use the events feed with since.

Deliveries can arrive out of order and more than once. Order by occurred_at and deduplicate on id. See Delivery is at least once.

#Verifying signatures

Check the signature on every delivery before trusting it:

  1. Read t and v1 from X-Ponch-Signature.
  2. Compute HMAC-SHA256 with the webhook's secret as the key (the string exactly as returned, UTF-8 encoded, not base64-decoded), over t, then a ., then the raw request body bytes.
  3. Compare the hex digest with v1 in constant time.
  4. Reject the delivery if t is more than 300 seconds from your current time. This stops someone resending an old delivery they captured.

Sign the bytes you received. If you parse the JSON and serialize it again, the bytes change and the signature won't match.

#Python
import hashlib
import hmac
import time


def verify_ponch_signature(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        ts = int(parts["t"])
        sig = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - ts) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

In Flask: verify_ponch_signature(SECRET, request.get_data(), request.headers.get("X-Ponch-Signature", "")). In Django: pass request.body.

#Node.js
const crypto = require("node:crypto");

function verifyPonchSignature(secret, rawBody, header, tolerance = 300) {
  const parts = Object.fromEntries(
    (header || "").split(",").map((p) => {
      const i = p.indexOf("=");
      return [p.slice(0, i), p.slice(i + 1)];
    }),
  );
  const ts = Number.parseInt(parts.t, 10);
  if (!Number.isFinite(ts) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

With Express, read the raw body so you sign the original bytes:

app.post("/ponch", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyPonchSignature(process.env.PONCH_WEBHOOK_SECRET, req.body, req.get("X-Ponch-Signature"))) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(200);
  // handle event...
});

Answer a bad signature with 400, not 410: a 410 would deactivate the webhook.

About this wiki

commit 5e140ef5493db7e98830f4b7ef4d620de5419518
Author: Peter Sanchez <peter@netlandish.com>
Date:   2026-10-20T06:45:49-06:00

add proper title and descriptions to each page
Clone this wiki ("main" branch)
https://git.code.netlandish.com/~netlandish/ponch-api-docs (read-only)
git@git.code.netlandish.com:~netlandish/ponch-api-docs (read/write)