Webhooks
Signed, versioned events pushed to your server — signing, delivery, retries, and the event catalogue.
Webhooks
Signed, versioned events pushed to your server the moment something happens — so you never poll.
Webhooks are part of the standard plan, not a paid gate, and every delivery rides the same reliability spine as the rest of the platform: events originate from an append-only event log, delivery intent is written transactionally to an outbox, and the dispatcher delivers them with retry, backoff, and a terminal dead-letter state.
How it works
Register an endpoint
POST /v1/webhooks with a receiving URL and the event types you want
(enabled_events: ["invoice.paid", "payment.*"], or ["*"] for all). StoreBay returns a
signing secret once — store it securely; you won't see it again.
StoreBay POSTs events
As they happen, each signed with X-StoreBay-Signature and carrying a payload pinned to your
endpoint's api_version.
Verify the signature
Extract t/v1 from the header, recompute the HMAC, and reject anything that doesn't match
before you trust the body (see Verifying signatures).
Respond 2xx fast
Ideally under 5 seconds. Do the real work asynchronously — a slow handler causes timeouts, and timeouts count as failures.
Failures are retried
With exponential backoff, up to ~8 attempts; you can inspect and manually redeliver any attempt (see Retries and failures).
Delivery is at-least-once — design handlers to be idempotent and dedupe on the event id.
Managing endpoints
Register and manage endpoints via the API (webhooks:write scope) or in your StoreBay developer
settings (https://app.storebay.co.uk).
| Field | Notes |
|---|---|
url | HTTPS receiving URL. |
enabled_events | The event types to deliver, or ["*"] for all. |
api_version | Payload version pinned per endpoint (date-based, e.g. 2026-07-03). Defaults to the latest at creation and does not move until you change it. |
status | active, disabled, or paused. |
secret | The signing secret — returned only once, in the creation response. |
Because api_version is pinned per endpoint, you can run several endpoints on different payload
versions and migrate them one at a time (see the Changelog).
The delivery envelope
Every delivery body is a JSON envelope; data carries the same resource shape as the REST API —
including money as integer minor units + currency.
| Field | Type | Meaning |
|---|---|---|
id | string | Unique event id — the platform.event_log row's own uuid, verbatim (the same identity the /v1/events feed uses for that event). Dedupe on this. |
type | string | The event type, e.g. invoice.paid. |
api_version | string | The payload version this body conforms to (your endpoint's pin). |
created | string | RFC 3339 UTC timestamp of the event. |
data | object | The event payload; data.object names the resource type. |
A sample delivery — one of Aisha Khan's invoices being paid:
POST /storebay/webhooks HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: StoreBay-Webhooks/1.0
StoreBay-Delivery: 018f9c2a-7b3e-7c1a-9f2d-3a5b6c7d8e9d
X-StoreBay-Signature: t=1751534400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd{
"id": "018f9c2a-7b3e-7c1a-9f2d-3a5b6c7d8e9e",
"type": "invoice.paid",
"api_version": "2026-07-03",
"created": "2026-07-06T09:15:00Z",
"data": {
"object": "invoice",
"id": "018f9c2b-1a2b-7c3d-9e4f-5a6b7c8d9e0f",
"status": "paid",
"total_minor": 3300,
"currency": "GBP"
}
}Verifying signatures
Every delivery carries a signature header:
X-StoreBay-Signature: t=<unix-ts>,v1=<hex HMAC-SHA256(endpoint_secret, "<t>.<raw-body>")>tis the Unix timestamp when the signature was generated.v1is the lowercase hex HMAC-SHA256 of the string"<t>.<raw-body>", keyed by your endpoint's signing secret. The scheme is Stripe-style and replay-safe via a timestamp tolerance window.- During secret rotation the header may contain multiple
v1=values (one per active secret); accept the delivery if any matches.
To verify: extract t and v1; reject stale timestamps (|now − t| over the tolerance,
default 300 s) to defeat replay of a captured request; recompute the expected signature over
"<t>." + raw request body — using the raw bytes exactly as received, never the re-serialised
parsed JSON, or whitespace and key ordering will break the HMAC; then compare with a
constant-time equality check, and only then parse and act on the body.
import hashlib
import hmac
import time
TOLERANCE = 300 # seconds
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts["t"])
# 1. Replay protection: reject timestamps outside the tolerance window.
if abs(time.time() - t) > TOLERANCE:
return False
# 2. Recompute HMAC-SHA256 over "<t>.<raw-body>" with the endpoint secret.
signed_payload = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
# 3. Constant-time compare (defends against timing attacks).
return hmac.compare_digest(expected, parts["v1"])const crypto = require('crypto')
const TOLERANCE = 300 // seconds
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
const t = Number(parts.t)
// 1. Replay protection: reject timestamps outside the tolerance window.
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE) return false
// 2. Recompute HMAC-SHA256 over "<t>.<raw-body>" with the endpoint secret.
const signedPayload = `${t}.${rawBody}`
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex')
// 3. Constant-time compare (defends against timing attacks).
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.v1, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Warning
Compare with a constant-time check — never ==, which can leak via timing.
Reject anything that fails verification with a 4xx and do not process it.
Retries and failures
The dispatcher delivers each event and records every attempt and outcome. A delivery moves through
pending → delivering → succeeded, or … → delivering → retrying → … → exhausted. A 2xx
response marks the delivery succeeded; any non-2xx, a timeout, or a connection error schedules a
retry with exponential backoff, up to ~8 attempts, after which the delivery is terminal
exhausted.
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 30 s |
| 3 | 2 min |
| 4 | 10 min |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 24 hours |
| 8 | 48 hours → then exhausted |
That's roughly 3.3 days total from the first attempt to exhausted — long enough that a genuine
weekend outage at your receiver still recovers without manual redelivery.
Manual redelivery. Inspect and replay any attempt:
GET /v1/webhooks/{id}/deliveries— list deliveries and their status.POST /v1/webhooks/{id}/redeliver— with{ "delivery_id": "…" }, queue a fresh attempt of a past (includingexhausted) delivery.attemptsis not reset — a redelivery at the attempt cap gets exactly one supervised attempt before re-exhausting.
Auto-disable. An endpoint is automatically disabled once it accumulates 50 consecutive
delivery failures and has had no successful delivery in the last 7 days — the two
conditions are conjoined so a single flapping deploy, recovered quickly, never disables an
endpoint on its own. You're notified, and the disablement itself is delivered as a
webhook_endpoint.disabled event to your other active endpoints; re-enable it
(PATCH status: "active") once the receiver is fixed — this also resets the failure count.
Note
Delivery is at-least-once. Dedupe on the event id and make handlers idempotent.
Warning
A Bacs payment.succeeded is a collection, not finality — payment.late_failure_settled can
arrive later and reverse it. Never treat it as terminal.
Event catalogue
Event types are stable, dotted names. data.object names the resource shape carried in data.
The catalogue is grouped by resource and grows additively.
| Resource | Event type | Fires when |
|---|---|---|
| contact | contact.created | A customer/prospect record is created. |
contact.updated | A contact's details change. | |
| reservation | reservation.created | A unit or unit type is held. |
reservation.cancelled | A reservation is cancelled or expires. | |
| order | order.created | A checkout order (basket) is created. |
order.completed | An order is completed (paid and provisioned). | |
| agreement | agreement.created | A storage licence agreement is drafted. |
agreement.signed | The customer signs the licence. | |
agreement.terminated | An agreement ends or is terminated. | |
| subscription | subscription.created | Recurring billing for an agreement begins. |
subscription.updated | Plan, price, or status changes. | |
subscription.cancelled | Recurring billing stops. | |
| invoice | invoice.created | An invoice is drafted. |
invoice.finalized | An invoice is issued/finalised. | |
invoice.paid | An invoice is fully paid. | |
invoice.payment_failed | A payment attempt against the invoice fails. | |
invoice.voided | An invoice is voided. | |
| payment | payment.succeeded | A payment collects (card authorised, or Bacs paid_out). |
payment.failed | A payment attempt fails. | |
payment.requires_action | A card off-session charge needs SCA/3DS step-up. | |
payment.refunded | A merchant refund is issued. | |
payment.late_failure_settled | A previously-"paid" Bacs DD collection is reversed by a late failure. | |
payment.chargeback_settled | A DD Guarantee clawback settles against a payment. | |
| mandate | mandate.created | A Bacs Direct Debit mandate is set up. |
mandate.active | A mandate becomes active and usable. | |
mandate.cancelled | A mandate is cancelled (e.g. via ADDACS). | |
mandate.failed | Mandate setup fails. | |
| credit_note | credit_note.created | A credit note is issued. |
| contract | contract.signed | An e-sign contract is signed. |
| identity_verification | identity_verification.verified | A KYC check passes. |
identity_verification.requires_input | A KYC check needs more input. | |
identity_verification.canceled | A KYC check is cancelled. | |
| access_credential | access_credential.issued | An entry credential is issued. |
access_credential.suspended | A credential is suspended (e.g. arrears/overlock). | |
access_credential.revoked | A credential is revoked. | |
| overlock | overlock.applied | A unit is overlocked (access denied for arrears). |
overlock.released | An overlock is released. | |
| dunning | dunning.advanced | A collections case advances a dunning step. |
dunning.paused | Dunning is paused (e.g. card step-up, dispute, or a hold). | |
dunning.resolved | Arrears are cleared and dunning resolves. | |
| price_increase | price_increase.scheduled | A rate change is scheduled. |
price_increase.applied | A scheduled rate change takes effect. | |
| message | message.sent | An email/SMS is dispatched. |
message.delivered | The provider confirms delivery. | |
message.bounced | A message bounces or fails delivery. | |
| accounting | accounting.synced | An accounting push/pull reconciles. |
accounting.failed | An accounting sync fails. | |
| deal | deal.created | A CRM deal (opportunity) is created. |
deal.won | A deal is marked won. | |
deal.lost | A deal is marked lost. | |
| task | task.created | A task is created. |
task.completed | A task is completed. | |
| note | note.created | A note is added to a subject. |
| import | import.completed | A bulk import (migration) job finishes successfully. |
import.failed | A bulk import job fails. | |
| webhook_endpoint | webhook_endpoint.disabled | An endpoint is auto-disabled after sustained delivery failure. |
Some families that exist in the data model are deliberately not published as webhooks yet:
standalone refund.* (signalled on the payment instead), deposit.* (no public deposits resource
yet), payout.* (operator-account concern, not yet a public resource), and sale_process.*/lien
(legally sensitive, back-office only — overlock.* is the integrator-relevant signal). None of
these are guessed gaps; see the API reference for what's live today.
Three catalogue entries above are accepted on enabled_events but have no delivery yet — you
can subscribe, but nothing will arrive until this ships: dunning.paused, import.failed,
invoice.payment_failed. Each has a sibling that fires today instead (dunning.advanced /
dunning.resolved; import.completed; payment.failed).
Best practices
- Verify the signature on every request before trusting the body.
- Respond
2xxfast; do the real work asynchronously. - Be idempotent: dedupe on
id; the same event may arrive more than once. - Tolerate unknown
types and new fields — the catalogue and payloads grow additively. - Order is not guaranteed; reconcile against the resource's current state via the REST API when
ordering matters, and use
createdto detect out-of-order arrivals. - Test in the sandbox with a
sb_test_…-owned endpoint before going live — see Sandbox.