Webhooks (Outbound)

When an event happens in your tenant, HummyTummy POSTs an HMAC-SHA256–signed HTTP request to your registered endpoint. This lets you react instantly to order, payment, and product/renewal events without polling.

All paths are under the /api global prefix.

⚠️

Webhook subscriptions require the API access entitlement (feature.apiAccess), which is unlocked by the API & Webhook Access module (api_access, annual, licence prerequisite). The subscription endpoints expect a staff JWT with the ADMIN role; without the entitlement they return 403 ENTITLEMENT_REQUIRED, and the envelope carries the product that unlocks it along with its price — see the Entitlement Matrix.

Subscribe

Create a subscription. The secret is returned once — store it securely; it is never shown again and cannot be re-derived.

curl -X POST https://hummytummy.com/api/v1/webhooks/subscriptions \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/hummytummy",
    "events": ["order.created.v1", "payment.succeeded.v1"]
  }'

Request body:

FieldRequiredDescription
urlYesThe https (or http) URL events are POSTed to. It passes the SSRF allowlist; the host is normalized to lowercase before storing
eventsNoEvent types to subscribe to. If omitted, ["*"] (all publishable events)

Response (excerpt):

{
  "id": "0190a1b2-...",
  "tenantId": "...",
  "url": "https://hooks.example.com/hummytummy",
  "events": ["order.created.v1", "payment.succeeded.v1"],
  "status": "active",
  "secret": "whs_AbCdEf..."
}

List / revoke subscriptions:

GET    /api/v1/webhooks/subscriptions
DELETE /api/v1/webhooks/subscriptions/:id

The default per-tenant active-subscription cap is 20 (only active rows are counted); exceeding it returns 400. Revoke unused subscriptions.

Delivered payload

Each delivery is a POST with a JSON body:

{
  "id": "0190a1b2-c3d4-...",
  "type": "order.created.v1",
  "tenantId": "...",
  "payload": { "...event-specific fields..." }
}

Plus these headers:

HeaderDescription
Content-Typeapplication/json
User-AgentHummyTummy-Webhook/1
X-HummyTummy-Event-IdThe event id (for idempotency)
X-HummyTummy-Event-TypeThe event type (e.g. order.created.v1)
X-HummyTummy-SignatureThe signature: t=<unix-ms>,v1=<hmac-sha256>

The same event may arrive more than once with the same id (at-least-once delivery). Dedup on id (or X-HummyTummy-Event-Id) in your receiver.

Verifying the signature

The signature is a timestamped HMAC-SHA256 computed over the body:

X-HummyTummy-Signature: t=<unix-ms>,v1=<hmac-sha256-hex>

It is produced like this — the signed string is "<timestamp>.<rawBody>", and the key is your secret:

v1 = HMAC_SHA256(secret, `${t}.${rawBody}`)  // hex

Verification steps (identical to HummyTummy’s own verify logic):

Parse the header

Split on commas and extract the t and v1 values.

Check the timestamp

t must be numeric and within ±5 minutes of now (replay protection).

Recompute the HMAC

Compute HMAC-SHA256 over "${t}.${rawBody}" with your secret.

Compare in constant time

Compare your computed hex against v1 with a length + constant-time (timing-safe) comparison. If they match, the event is authentic.

⚠️

Use the raw body — do NOT parse and re-serialize the JSON; that changes the bytes and the signature won’t match. Capture the raw body buffer in your web framework.

Node.js (Express) verification example

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
 
const app = express();
 
// Capture the raw body — the signature is computed over the raw bytes.
app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));
 
function verify(
  secret: string,
  header: string,
  rawBody: string,
  toleranceMs = 5 * 60_000,
): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const ts = Number(parts.t);
  const v1 = String(parts.v1 ?? "");
  if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > toleranceMs) return false;
  const expected = createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");
  if (expected.length !== v1.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
 
app.post("/hummytummy", (req, res) => {
  const sig = req.header("X-HummyTummy-Signature") ?? "";
  const raw = (req as any).rawBody.toString("utf8");
  if (!verify(process.env.WEBHOOK_SECRET!, sig, raw)) {
    return res.sendStatus(401); // invalid/stale signature
  }
  // Idempotency: don't process the same X-HummyTummy-Event-Id twice.
  const eventId = req.header("X-HummyTummy-Event-Id");
  // ... handle the event ...
  res.sendStatus(200); // 2xx = successful delivery
});

Return 2xx (200–299) for a successful delivery. Any non-2xx is treated as a failure and retried.

Event types

List specific types in the events field, or use "*" to catch all publishable events. Types follow the <area>.<action>.v<version> shape.

Example publishable types:

Event typeWhen
order.created.v1A new order was created
order.updated.v1An order was updated
order.completed.v1An order completed
order.cancelled.v1An order was cancelled
payment.succeeded.v1A payment succeeded
payment.intent_created.v1A payment intent was created
payment.refund_completed.v1A refund completed
checkout.completed.v1A checkout completed (mixed cart provisioned)
addon.purchased.v1A catalog product was bought or renewed
addon.cancelled.v1A product was cancelled
addon.past_due.v1A product’s paid period ended; the 7-day grace window started
renewal.reminder.v130 / 7 / 1 days left to the anniversary
feature.entitlement.changed.v1The entitlement set changed

The subscription.* types remain in the event vocabulary, but with the plan rail retired no paid lifecycle produces them any more. New integrations should listen for addon.*, renewal.* and checkout.completed.v1.

Blocked events

Some sensitive events are kept internal and are never sent out — not even a "*" subscription receives them. Blocking is by prefix; no event whose type starts with the following prefixes is delivered:

user.password
user.email_verification
auth.
subscription.upgrade.requested
subscription.renewal.failed
subscription.payment.
kms.
audit.

New business events are publishable by default; only sensitive ones are added to this list. The filter runs before subscription matching — even an explicit, named subscription cannot opt into a blocked event.

Retries and auto-pause

Delivery is performed by a worker that runs every 30 seconds.

  • Timeout: a single delivery is capped at 15 seconds.
  • Retry: on a non-2xx response or network error, up to 5 attempts with increasing backoff (30 s, 2 m, 10 m, 1 h, 6 h); after that the delivery is marked failed.
  • Auto-pause: a subscription is automatically set to paused after 20 consecutive failures. This stops a dead endpoint from draining the worker; you must fix it and create a new subscription.
  • Success resets the counter: a successful delivery resets the consecutive-failure counter to 0.
⚠️

If the payload can’t be loaded (e.g. the source event was purged by retention), the delivery is marked failed with an actionable note (source event purged before delivery) rather than silently losing data.

SSRF protection (allowlist)

Webhook URLs pass a safety gate both at subscribe time and immediately before each delivery. This prevents a tenant from self-fetching internal services or cloud metadata endpoints (e.g. 169.254.169.254).

What’s blocked:

  • Non-public IPs: loopback, private/internal ranges, link-local, cloud-metadata addresses, and their IPv4-mapped IPv6 equivalents.
  • Dangerous ports (Redis, Postgres, etc.).
  • Userinfo in the URL (user:pass@host).
  • Protocols other than http/https.

The re-check before delivery also closes DNS-rebind attacks (a DNS server that answers “public IP” at subscribe and “private IP” at delivery). In that case the delivery is marked failed without being retried.

Best practices

  • Always verify the signature and dedup on id.
  • Return 2xx quickly; queue heavy work (stay under the 15 s timeout).
  • Store the secret server-side, in an environment variable / secret vault.
  • If you find a subscription paused, fix your endpoint and re-subscribe.
  • Use an HTTPS endpoint where possible.