StoreBay Developers

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).

FieldNotes
urlHTTPS receiving URL.
enabled_eventsThe event types to deliver, or ["*"] for all.
api_versionPayload 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.
statusactive, disabled, or paused.
secretThe 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.

FieldTypeMeaning
idstringUnique 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.
typestringThe event type, e.g. invoice.paid.
api_versionstringThe payload version this body conforms to (your endpoint's pin).
createdstringRFC 3339 UTC timestamp of the event.
dataobjectThe 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>")>
  • t is the Unix timestamp when the signature was generated.
  • v1 is 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.

AttemptDelay after previous
1immediate
230 s
32 min
410 min
51 hour
66 hours
724 hours
848 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 (including exhausted) delivery. attempts is 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.

ResourceEvent typeFires when
contactcontact.createdA customer/prospect record is created.
contact.updatedA contact's details change.
reservationreservation.createdA unit or unit type is held.
reservation.cancelledA reservation is cancelled or expires.
orderorder.createdA checkout order (basket) is created.
order.completedAn order is completed (paid and provisioned).
agreementagreement.createdA storage licence agreement is drafted.
agreement.signedThe customer signs the licence.
agreement.terminatedAn agreement ends or is terminated.
subscriptionsubscription.createdRecurring billing for an agreement begins.
subscription.updatedPlan, price, or status changes.
subscription.cancelledRecurring billing stops.
invoiceinvoice.createdAn invoice is drafted.
invoice.finalizedAn invoice is issued/finalised.
invoice.paidAn invoice is fully paid.
invoice.payment_failedA payment attempt against the invoice fails.
invoice.voidedAn invoice is voided.
paymentpayment.succeededA payment collects (card authorised, or Bacs paid_out).
payment.failedA payment attempt fails.
payment.requires_actionA card off-session charge needs SCA/3DS step-up.
payment.refundedA merchant refund is issued.
payment.late_failure_settledA previously-"paid" Bacs DD collection is reversed by a late failure.
payment.chargeback_settledA DD Guarantee clawback settles against a payment.
mandatemandate.createdA Bacs Direct Debit mandate is set up.
mandate.activeA mandate becomes active and usable.
mandate.cancelledA mandate is cancelled (e.g. via ADDACS).
mandate.failedMandate setup fails.
credit_notecredit_note.createdA credit note is issued.
contractcontract.signedAn e-sign contract is signed.
identity_verificationidentity_verification.verifiedA KYC check passes.
identity_verification.requires_inputA KYC check needs more input.
identity_verification.canceledA KYC check is cancelled.
access_credentialaccess_credential.issuedAn entry credential is issued.
access_credential.suspendedA credential is suspended (e.g. arrears/overlock).
access_credential.revokedA credential is revoked.
overlockoverlock.appliedA unit is overlocked (access denied for arrears).
overlock.releasedAn overlock is released.
dunningdunning.advancedA collections case advances a dunning step.
dunning.pausedDunning is paused (e.g. card step-up, dispute, or a hold).
dunning.resolvedArrears are cleared and dunning resolves.
price_increaseprice_increase.scheduledA rate change is scheduled.
price_increase.appliedA scheduled rate change takes effect.
messagemessage.sentAn email/SMS is dispatched.
message.deliveredThe provider confirms delivery.
message.bouncedA message bounces or fails delivery.
accountingaccounting.syncedAn accounting push/pull reconciles.
accounting.failedAn accounting sync fails.
dealdeal.createdA CRM deal (opportunity) is created.
deal.wonA deal is marked won.
deal.lostA deal is marked lost.
tasktask.createdA task is created.
task.completedA task is completed.
notenote.createdA note is added to a subject.
importimport.completedA bulk import (migration) job finishes successfully.
import.failedA bulk import job fails.
webhook_endpointwebhook_endpoint.disabledAn 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 2xx fast; 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 created to detect out-of-order arrivals.
  • Test in the sandbox with a sb_test_…-owned endpoint before going live — see Sandbox.

On this page