Webhooks

Webhooks let ibakepro notify your server when something happens - an order is created, a payment completes, a product changes, stock runs low. You manage endpoints through the v2 API (or the dashboard), and ibakepro POSTs signed JSON events to your URL.

The webhook management API requires a secret key (ibp_sk_) with the webhooks:manage scope. The signing secret is returned only when you create or rotate an endpoint - it never appears on a read.

Managing endpoints

  • Name
    POST /api/v2/webhooks
    Description

    Register a new endpoint. Returns 201 with the signing secret (shown once).

  • Name
    GET /api/v2/webhooks
    Description

    List endpoints (cursor-paginated, newest first). No secret in the response.

  • Name
    GET /api/v2/webhooks/{id}
    Description

    Fetch one endpoint.

  • Name
    PATCH /api/v2/webhooks/{id}
    Description

    Update url, events, description, or enabled.

  • Name
    DELETE /api/v2/webhooks/{id}
    Description

    Hard-delete an endpoint. Returns { "deleted": true, "id": "a1B2c3D4e5F6g7H8i9J0" }.

  • Name
    POST /api/v2/webhooks/{id}/rotate-secret
    Description

    Mint a new signing secret. The old one stays valid for 24 hours.

  • Name
    POST /api/v2/webhooks/{id}/test
    Description

    Send a one-off webhook.test event to confirm reachability.

  • Name
    GET /api/v2/webhooks/{id}/events
    Description

    List delivery attempts (cursor-paginated). Optional status filter.

Every route above requires the webhooks:manage scope. There is no publishable-key access to any of them.

Register an endpoint

Register a URL and the event types to subscribe to. The URL must be HTTPS and pass the host checks. Every entry in events must be a subscribable type; an unknown type is a 400.

A maximum of 10 endpoints per account - the eleventh create returns 400. Registering a URL that is already registered on this account returns 409.

The signing secret in the 201 response is shown only once - store it. POST honours Idempotency-Key.

Required scope: webhooks:manage

Request

POST
/api/v2/webhooks
curl -X POST https://au.api.ibakepro.com/api/v2/webhooks \
  -H "Authorization: Bearer {secret_key}" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/ibakepro",
    "events": ["order.created", "payment.completed"],
    "description": "Order sync"
  }'

Response (201)

{
  "id": "a1B2c3D4e5F6g7H8i9J0",
  "url": "https://example.com/hooks/ibakepro",
  "description": "Order sync",
  "events": ["order.created", "payment.completed"],
  "enabled": true,
  "status": "active",
  "disabled_reason": null,
  "delivery_stats": {
    "success_count": 0,
    "failure_count": 0,
    "consecutive_failures": 0,
    "last_delivery_at": null,
    "last_success_at": null,
    "last_failure_at": null
  },
  "created_at": "2026-06-17T10:00:00.000Z",
  "updated_at": "2026-06-17T10:00:00.000Z",
  "secret": "whsec_ABC"
}

The secret (prefix whsec_) appears only here and in the rotate-secret response. Every other read (GET, list, PATCH) returns the same shape without secret; list items also omit disabled_reason. status is derived from enabled, not stored independently. The id is an opaque string - do not assume a prefix.

Rotate a secret

Returns { "id": "a1B2c3D4e5F6g7H8i9J0", "secret": "whsec_ABC", "previous_secret_expires_at": "2026-06-18T10:00:00.000Z" }. See secret rotation for how deliveries behave during the grace window.

Send a test event

Delivers one webhook.test event immediately and returns { "success", "status_code", "response_time_ms", "error" }. A test is sent once with no retries, and it does not touch delivery_stats, so a failing test never moves you toward auto-disable. Its data.object is { "message": "This is a test webhook from iBakePro", "timestamp": "<ISO 8601>" }. Testing a disabled endpoint returns 409 without sending anything.

error is null when the endpoint answered with a 2xx. Otherwise it is one of four values:

errorMeaning
invalid_urlThe stored URL does not meet the requirements. No request was sent. The specific reason is not reported.
redirect_not_followedThe endpoint answered with a 3xx. Redirects are never followed.
http_errorThe endpoint answered with a non-2xx, non-3xx status. Read status_code.
no_responseNo HTTP response was received.

status_code and response_time_ms are both null whenever no HTTP response was received, which is every no_response and invalid_url result. They carry values only when error is null, http_error or redirect_not_followed.

no_response is not broken down further. A timeout, a DNS failure, a refused connection, a reset connection and a TLS failure all report no_response with no timing, and are not distinguishable from each other through this endpoint.

This endpoint has its own rate limit of 10 requests per 60 seconds per account, applied on top of the general API limits. Exceeding it returns 429.

List delivery attempts

Cursor-paginated, newest first. Optional status query parameter: pending, delivered, or failed; any other value is a 400. Each item carries id, type, status, attempts, last_status_code, last_error, next_retry_at, last_attempt_at, delivered_at, failed_at, created_at.

id here is the event id (evt_...) the receiver saw in the payload and in X-Webhook-Id. The log is scoped to one endpoint, so if the same event went to several endpoints each one has its own record.

Read the statuses like this:

statusMeaning
pendingNot yet delivered and still eligible for retry. attempts counts the failures so far and next_retry_at is when the next attempt becomes due.
deliveredAn attempt returned 2xx. delivered_at is set and next_retry_at is null.
failedTerminal. Either all 5 attempts were used, or the retry came due while the endpoint was disabled or deleted. It will not be tried again. failed_at is set and last_error carries the outcome of the last attempt.

last_error uses the same values as the test endpoint above, plus endpoint_disabled. It is null when there is nothing to report:

last_errorMeaning
invalid_urlThe endpoint URL did not meet the requirements at delivery time. No request was sent. The specific reason is not reported.
redirect_not_followedThe endpoint answered with a 3xx.
http_errorThe endpoint answered with a non-2xx, non-3xx status. Read last_status_code.
no_responseNo HTTP response was received.
endpoint_disabledThe retry came due while the endpoint was disabled or deleted, so no attempt was made.

As on the test endpoint, no_response is not broken down further: a timeout, a DNS failure, a refused connection, a reset connection and a TLS failure all read as no_response and are not distinguishable from each other in the delivery log.

last_status_code is the HTTP status of the most recent attempt, and is null whenever no HTTP response was received. A cursor pointing at an entry that no longer exists returns 400 invalid_cursor; restart the listing without a cursor rather than treating it as the end of the log.

Subscribable events

There are 20 subscribable event types. Choose one or more when registering an endpoint.

CategoryEvent types
Ordersorder.created, order.updated, order.status_changed, order.deleted
Customerscustomer.created, customer.updated
Productsproduct.created, product.updated, product.deleted
Paymentspayment.created, payment.updated, payment.completed, payment.failed, payment.refunded
Quick salesquick_sale.created, quick_sale.updated, quick_sale.deleted
Expensesexpense.created, expense.updated
Inventoryinventory.low

webhook.test is sent only by the test endpoint. It is not subscribable and never arrives for real activity.

The envelope

Every delivery is a JSON POST with this top-level structure:

Webhook payload

{
  "id": "evt_9f2c8b41-6d3e-4a7f-b0c5-1e83d7a4f2b6",
  "type": "order.created",
  "created": 1781690400,
  "api_version": "v1",
  "data": { "object": { } },
  "tenant_id": "<your tenant id>"
}
  • The event payload is under data.object - not directly under data.
  • created is a Unix timestamp in seconds, set when the event was built (not when the current attempt was sent).
  • api_version is the version of this envelope, and it is "v1" on every delivery today. It is not the API surface version: v2 API endpoints emit api_version: "v1" events, and that is correct, not a bug. The two are versioned independently - api_version changes only if the envelope structure changes.
  • id identifies the event. It is stable across all retries of that event, so it is the value to deduplicate on. The same id is also sent to every endpoint of yours subscribed to that event, so key your dedupe on the pair (endpoint, event id) if you run more than one endpoint against the same handler.

Order events

order.* events carry the order summary: id, order_number, friendly_order_number, external_id, status, payment_status, subtotal, tax, delivery_fee, discount, total, currency, customer_id, customer_name, customer_email, customer_phone, delivery_type, delivery_date, delivery_time, items_count, order_type, source, created_at, updated_at.

currency falls back to "AUD" when the order carries none. items_count is an integer, not an array. previous_status is present only on order.status_changed, and only when a previous status was recorded; it is omitted entirely on the other three order events.

Customer events

customer.* events carry id, first_name, last_name, email, phone, company, tags, is_blacklisted, total_orders, total_spent, last_order_date, marketing_email, marketing_sms, external_id, created_at, updated_at.

total_orders and total_spent default to 0. marketing_email and marketing_sms are null when the customer has no recorded preference.

Product events

product.* events carry exactly five fields:

product.updated data.object

{
  "id": "prod_ze9HI0w0LpVbemUI",
  "name": "Chocolate Birthday Cake",
  "status": "active",
  "storefront_visible": true,
  "updated_at": "2026-06-17T10:00:00.000Z"
}

The event carries no variant, choice, pricing or allergen data. Call GET /api/v2/products/{id} (scope products:read) for the full current record.

  • storefront_visible is the canonical storefront predicate: active, not hidden from the storefront, and not deleted. status alone does not carry the last two.
  • updated_at is the stored update time as ISO 8601. On product.created the stored value is still a server-timestamp placeholder at emit time, so the event carries the emit time instead.
  • product.deleted is a soft delete. The record still exists in ibakepro with status "deleted"; it is removed from search and from every storefront surface. A deleted product is not returned by GET /api/v2/products/{id}, so read status and storefront_visible from the event itself for that case.
  • product.updated fires on every save, including a bare status flip or a storefront show/hide. It does not carry which fields changed.

Payment events

payment.* events carry id, order_id, customer_id, schedule_id, amount, tip_amount, total_amount, fee_amount, net_amount, type, source, method, status, previous_status, method_details ({ brand, last4, wallet_type } or null), gateway ({ provider, transaction_id } or null), original_payment_id, refunded_amount, description, created_at, completed_at.

previous_status is always present on payment.* and is null when the event is not a status change. That differs from order.status_changed, where the field is omitted rather than null.

When each payment event fires

EventFires when
payment.createdA payment record is created. Refund records are the exception - see below.
payment.completedA payment reaches completed (or the equivalent staff-approved status).
payment.updatedAny status write against an existing payment, plus voiding a payment and correcting its amount.
payment.failedA payment moves to failed, for example when staff reject a submitted bank transfer.
payment.refundedA refund record is created against an original payment.

Pairing and ordering rules to code against:

  • A payment created already completed (card capture, manually recorded cash, a bank transfer entered as received) emits both payment.created and payment.completed, in that order. Do not assume payment.completed is only ever preceded by a separate pending state.
  • A payment created as pending emits payment.created only. payment.completed follows later, alongside payment.updated, when it is approved or captured. A payment created already failed emits payment.created then payment.failed.
  • payment.completed and payment.failed fire on the transition only. A repeated write of the same status emits payment.updated and nothing else, so you will not receive a duplicate terminal event.
  • Refunds emit payment.refunded alone. A refund record does not also emit payment.created or payment.completed, even though it is stored as a completed record.
  • Voiding a payment emits payment.updated with the voided status and previous_status set to the status it held before the void. There is no payment.voided event type.
  • Correcting a payment's amount also emits payment.updated. The status does not change, so previous_status equals status; read amount and total_amount for the corrected figures. Void and amend both move the order's paid totals.
  • On create-time events (payment.created, and the payment.completed or payment.failed that accompanies it) previous_status is null.
  • A payment recorded on POST /api/v2/orders emits nothing by default. Subscribing to payment.* is not enough: the create body must set emit_webhooks: true. The gate covers payment events only, so order.created still fires. See Payments. Payments taken through a hosted checkout session are unaffected and always emit.
  • Emission is best-effort and happens after the payment write has committed. A delivery failure never rolls back the payment.

Quick sale events

quick_sale.* events carry id, date, description, sale_type, location, event_name, gross_sales, gross_cost, net_amount, payment_method, items_count, created_at, updated_at. items_count is an integer count, not an array.

Expense events

expense.* events carry id, amount, currency, category, description, date (always ISO 8601, converted from a Unix-seconds value if that is what was stored), status, supplier_id, supplier_name, payment_status, payment_method, payment_reference, external_id, created_at, updated_at.

Inventory events

inventory.low carries id, type, name, category, current_stock, reorder_point, unit, supplier_id, supplier_name. It has no timestamp fields.

Verifying webhooks

Headers on every delivery

HeaderDescription
X-Webhook-Signaturet=<unix seconds>,v1=<hex HMAC>
X-Webhook-IdThe event id (evt_...) - same value as Idempotency-Key. Use it to deduplicate.
X-Webhook-TimestampUnix timestamp in seconds (same value as t= in the signature header)
Idempotency-KeyThe event id (evt_...)
X-Webhook-Signature-PreviousPresent only during a 24-hour secret rotation grace period
User-AgentiBakePro-Webhooks/1.0 on every delivery, including webhook.test
Content-Typeapplication/json

The webhook endpoint id is not sent in any delivery header, so a delivery does not identify which registered endpoint it was addressed to beyond the URL it arrived on.

t is the time the attempt was signed, not the time the event was created, so a retried delivery carries a fresh t and a fresh signature over an unchanged body.

Computing the HMAC

The signed payload is "<t>.<rawBody>" where t is the seconds timestamp from the t= field and rawBody is the raw request body bytes:

HMAC = HMAC_SHA256(secret, "<t>.<rawBody>")

Node verification

const crypto = require('crypto')

function verify(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => {
      const i = p.indexOf('=')
      return [p.slice(0, i), p.slice(i + 1)]
    }),
  )
  const t = parts.t
  if (!t || !parts.v1) throw new Error('malformed signature header')

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.`)
    .update(rawBody)
    .digest('hex')

  const a = Buffer.from(expected, 'utf8')
  const b = Buffer.from(parts.v1, 'utf8')
  // timingSafeEqual throws on a length mismatch, so length-check first.
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error('bad signature')
  }
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
    throw new Error('stale timestamp')
  }
}

Hash the raw request body bytes exactly as received (e.g. await req.text()). Do not re-serialize with JSON.stringify(JSON.parse(body)) - even a single space difference breaks the HMAC. Chain .update(t + '.').update(rawBody) as above rather than interpolating: ${buffer} coerces a Node Buffer to "[object Buffer]" and the HMAC will never match. And use the t= value from inside X-Webhook-Signature, not the separate X-Webhook-Timestamp header - the freshness check must read the same timestamp that was signed.

The 5-minute tolerance in the sample is not something ibakepro enforces. t is signed into every delivery; the freshness window you accept is yours to set.

Secret rotation grace period

When you rotate a secret via POST /api/v2/webhooks/{id}/rotate-secret, the previous secret stays valid for 24 hours (the exact expiry is returned as previous_secret_expires_at). During that window, deliveries include both X-Webhook-Signature (new secret) and X-Webhook-Signature-Previous (old secret), each covering the same signed payload. Accept the delivery if either verifies, so you can deploy the new secret with no downtime. Once the window closes, the previous-signature header stops being sent.

Every attempt is signed when it is sent, not when the event was built. An event that was queued or is mid-retry when you rotate is therefore signed with the new secret from its next attempt onward, carrying the previous-secret header while the window is open. There is no backlog signed with only the old secret.

Delivery semantics

  • Attempts: 5 total. Backoff after each failure is immediate, then 5 minutes, 30 minutes, 2 hours, 24 hours. Retries are picked up by a job that runs every 5 minutes, so those delays are minimums.
  • Timeout: 30 seconds per attempt. A timeout counts as a failure.
  • Success: any 2xx response returned within the 30-second timeout. A response that arrives later is a failed attempt and is retried.
  • Redirects are not followed. Deliveries are sent with manual redirect handling, so any 3xx response is a failed attempt, not a success, and it consumes a retry. Point the endpoint at its final URL. This includes the common cases of a host-level redirect to www. or a trailing-slash normalisation.
  • Any non-2xx, non-3xx status is a failure and is retried on the same schedule.
  • Deduplication: dedupe on the event id (evt_...), available as the payload id and the X-Webhook-Id and Idempotency-Key headers. The id is stable across retries.
  • Delivery is at-least-once and not ordered. Events are dispatched independently, so a later event can arrive before an earlier one, particularly after a retry. Use updated_at (or refetch) rather than assuming arrival order.
  • Auto-disable: an endpoint is disabled after 10 consecutive failures, across all events. enabled flips to false, status to "disabled" and disabled_reason is populated. Re-enable via PATCH /api/v2/webhooks/{id} with { "enabled": true }. A successful delivery resets consecutive_failures to 0.
  • Pending events for a disabled or deleted endpoint are dropped. When a retry comes due and the endpoint is gone or disabled, the event is marked permanently failed rather than held. Re-enabling does not flush a backlog.
  • The URL is re-validated at delivery time, not only at registration. If an endpoint URL no longer meets the requirements below, the attempt fails and flows into the normal retry and auto-disable path.

Network requirements

  • TLS is required and the certificate is verified. Your endpoint must present a certificate that is valid and trusted by a public certificate authority. A self-signed or expired certificate, or an incomplete chain, aborts the connection and counts as a failed attempt with no status code (last_status_code is null).
  • Source IP addresses are not published and are not fixed. Deliveries do not come from a stable range, so a source-IP allowlist is not something we can support. The signature is what proves a delivery came from ibakepro.
  • Each delivery is a single event, not a batch. Body size follows the event payload, so a record with many line items produces a larger body. There is no fixed size limit.
  • webhook.test uses the same headers, signature and User-Agent as a real delivery.

Endpoint URL restrictions

Your endpoint must be a publicly reachable HTTPS URL. A URL is rejected when:

  • it is not https://
  • it carries credentials (user:pass@host)
  • it points at a private, internal, loopback, link-local or cloud-metadata address

The same requirements apply at registration and again at delivery time, so an endpoint that stops meeting them starts failing attempts and moves through the normal retry and auto-disable path.

Not yet available

  • Replay of a past delivery is not supported. You can send a webhook.test to confirm reachability, but a specific past event cannot be re-sent.
  • Event-property filtering is not supported - you subscribe to a type and receive all events of that type.