The API is included in the plan. The account owner creates keys in the developer settings. For a test account, email hello@storebay.co.uk.

Developers

Webhooks

Signed, versioned events pushed to your server — signing, delivery, retries, and the event catalogue.

Signed, versioned events pushed to your server as things happen. Use the REST API to read a full record, or to catch up after your server has been down.

Webhooks are included in the plan, 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 and backoff, and a delivery that keeps failing is marked exhausted so you can send it again.

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.

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 GoCardless confirming the collection, not finality. A collection returned before the payout arrives as payment.failed (the same payment, now failed), and one returned after it as payment.late_failure_settled or payment.chargeback_settled. A reversed collection reopens its invoice. Never treat a Bacs payment.succeeded 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: a website or API hold, or a unit staff reserve for a customer from their enquiry.
reservation.cancelledA reservation is cancelled: through the API, by staff, or because its deal was marked lost.
reservation.expiredA reservation's hold runs out before it is booked or cancelled: found by the sweep that runs every 15 minutes, or by staff acting on it first. The payload is the reservation, status expired. No email is sent.
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.access_heldThe booking is active but its access is waiting for the customer's ID to be checked: no credential has been issued and no "your unit is ready" email sent. Fires straight after agreement.signed: don't grant access on agreement.signed when this follows it.
agreement.access_releasedThe ID check passed (or staff let the customer in) and the access that was held has been given.
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: a card is charged, or GoCardless confirms a Bacs Direct Debit (the money is collected and the invoice is paid; the payout follows days later and sends no event). For a Direct Debit, data.provider_charge_date is the day the bank took the money.
payment.failedA payment attempt fails, or a Bacs Direct Debit that was collected is returned before it is paid out.
payment.requires_actionA card off-session charge needs SCA/3DS step-up.
payment.refundedA merchant refund is issued.
payment.late_failure_settledA Bacs DD collection that was already paid out 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 customer's online ID check passes, or a member of the operator's staff accepts one that did not pass as the same person under another form of their name.
identity_verification.requires_inputAn online ID check could not be confirmed and now waits for a member of the operator's staff to review it. The customer is not refused; the operator decides.
identity_verification.canceledAn online ID check ended with no decision: the customer left it, it ran out of time, they withdrew their consent, or the booking it was for ended. Nothing is billed.
access_credentialaccess_credential.createdAn 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), once a connected gate or lock system confirms it.
overlock.releasedAn overlock is released, once a connected gate or lock system confirms it.
dunningdunning.advancedA collections case advances a dunning step.
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.

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 with a sb_test_…-owned endpoint on a test account before you point anything at a live one: see Sandbox.

On this page