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
201with 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, orenabled.
- 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.testevent to confirm reachability.
- Name
GET /api/v2/webhooks/{id}/events- Description
List delivery attempts (cursor-paginated). Optional
statusfilter.
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
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:
error | Meaning |
|---|---|
invalid_url | The stored URL does not meet the requirements. No request was sent. The specific reason is not reported. |
redirect_not_followed | The endpoint answered with a 3xx. Redirects are never followed. |
http_error | The endpoint answered with a non-2xx, non-3xx status. Read status_code. |
no_response | No 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:
status | Meaning |
|---|---|
pending | Not yet delivered and still eligible for retry. attempts counts the failures so far and next_retry_at is when the next attempt becomes due. |
delivered | An attempt returned 2xx. delivered_at is set and next_retry_at is null. |
failed | Terminal. 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_error | Meaning |
|---|---|
invalid_url | The endpoint URL did not meet the requirements at delivery time. No request was sent. The specific reason is not reported. |
redirect_not_followed | The endpoint answered with a 3xx. |
http_error | The endpoint answered with a non-2xx, non-3xx status. Read last_status_code. |
no_response | No HTTP response was received. |
endpoint_disabled | The 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.
| Category | Event types |
|---|---|
| Orders | order.created, order.updated, order.status_changed, order.deleted |
| Customers | customer.created, customer.updated |
| Products | product.created, product.updated, product.deleted |
| Payments | payment.created, payment.updated, payment.completed, payment.failed, payment.refunded |
| Quick sales | quick_sale.created, quick_sale.updated, quick_sale.deleted |
| Expenses | expense.created, expense.updated |
| Inventory | inventory.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 underdata. createdis a Unix timestamp in seconds, set when the event was built (not when the current attempt was sent).api_versionis the version of this envelope, and it is"v1"on every delivery today. It is not the API surface version: v2 API endpoints emitapi_version: "v1"events, and that is correct, not a bug. The two are versioned independently -api_versionchanges only if the envelope structure changes.ididentifies the event. It is stable across all retries of that event, so it is the value to deduplicate on. The sameidis 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_visibleis the canonical storefront predicate: active, not hidden from the storefront, and not deleted.statusalone does not carry the last two.updated_atis the stored update time as ISO 8601. Onproduct.createdthe stored value is still a server-timestamp placeholder at emit time, so the event carries the emit time instead.product.deletedis a soft delete. The record still exists in ibakepro withstatus"deleted"; it is removed from search and from every storefront surface. A deleted product is not returned byGET /api/v2/products/{id}, so readstatusandstorefront_visiblefrom the event itself for that case.product.updatedfires 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
| Event | Fires when |
|---|---|
payment.created | A payment record is created. Refund records are the exception - see below. |
payment.completed | A payment reaches completed (or the equivalent staff-approved status). |
payment.updated | Any status write against an existing payment, plus voiding a payment and correcting its amount. |
payment.failed | A payment moves to failed, for example when staff reject a submitted bank transfer. |
payment.refunded | A 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.createdandpayment.completed, in that order. Do not assumepayment.completedis only ever preceded by a separate pending state. - A payment created as pending emits
payment.createdonly.payment.completedfollows later, alongsidepayment.updated, when it is approved or captured. A payment created already failed emitspayment.createdthenpayment.failed. payment.completedandpayment.failedfire on the transition only. A repeated write of the same status emitspayment.updatedand nothing else, so you will not receive a duplicate terminal event.- Refunds emit
payment.refundedalone. A refund record does not also emitpayment.createdorpayment.completed, even though it is stored as a completed record. - Voiding a payment emits
payment.updatedwith the voided status andprevious_statusset to the status it held before the void. There is nopayment.voidedevent type. - Correcting a payment's amount also emits
payment.updated. The status does not change, soprevious_statusequalsstatus; readamountandtotal_amountfor the corrected figures. Void and amend both move the order's paid totals. - On create-time events (
payment.created, and thepayment.completedorpayment.failedthat accompanies it)previous_statusisnull. - A payment recorded on
POST /api/v2/ordersemits nothing by default. Subscribing topayment.*is not enough: the create body must setemit_webhooks: true. The gate covers payment events only, soorder.createdstill 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
| Header | Description |
|---|---|
X-Webhook-Signature | t=<unix seconds>,v1=<hex HMAC> |
X-Webhook-Id | The event id (evt_...) - same value as Idempotency-Key. Use it to deduplicate. |
X-Webhook-Timestamp | Unix timestamp in seconds (same value as t= in the signature header) |
Idempotency-Key | The event id (evt_...) |
X-Webhook-Signature-Previous | Present only during a 24-hour secret rotation grace period |
User-Agent | iBakePro-Webhooks/1.0 on every delivery, including webhook.test |
Content-Type | application/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
2xxresponse 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
3xxresponse 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 towww.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 payloadidand theX-Webhook-IdandIdempotency-Keyheaders. 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.
enabledflips tofalse,statusto"disabled"anddisabled_reasonis populated. Re-enable viaPATCH /api/v2/webhooks/{id}with{ "enabled": true }. A successful delivery resetsconsecutive_failuresto 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_codeisnull). - 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.testuses the same headers, signature andUser-Agentas 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.testto 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.