Webhooks let ByeLabel push events to your server the moment they happen — an order is created, a label is bought, a shipment is tracked — so you don't have to poll the API.
When something happens in your workspace, ByeLabel sends an HTTP POST to a URL you choose, signed with a secret only you and ByeLabel know.
something happens ByeLabel your server
in your workspace ──────► signs + POSTs ──────► https://you.com/hooks
│
retries on failure ◄─────────────────┘
200 OKWebhook endpoints are managed in the ByeLabel panel under Settings → Webhooks (/settings/webhooks). You need the Webhooks permission under Settings.
Webhook endpoints are not managed through the public REST API — the panel is the only place to create, edit or delete them.
Click Add webhook and fill in:
| Field | Notes |
|---|---|
| Name | Anything that helps you recognise it later, e.g. "ERP bridge (production)". Required, up to 150 characters. |
| URL | Where to send the requests. Must be http:// or https://, up to 500 characters. Use HTTPS in production. |
| Events | Which events to receive — at least one. See the event reference. |
| Enabled | Turn delivery on or off without deleting the endpoint. |
| Max retries | How many times to retry a failed delivery. Default 5, maximum 20. |
| Custom headers | Optional extra headers sent with every request — handy for an API key or tenant id your side expects. Up to 20, values up to 2000 characters. |
You can subscribe to exact names or use wildcards:
| You subscribe to | You receive |
|---|---|
order.created | just that event |
order.* | every order. event — order.created, order.updated, order.deleted |
* | every event ByeLabel emits, including ones added in the future |
Subscribe only to what you actually process — every extra event is another request your server has to answer inside the 10-second budget.
Each endpoint gets its own secret, shown as whsec_… under the endpoint's Overview tab. Copy it into your application's configuration — you need it to verify signatures.
Treat it like a password: never commit it, never put it in front-end code. If it leaks, click Rotate secret.
When you rotate, the new secret applies to the next delivery. Requests already queued still carry the old one, so accept both secrets for a few minutes during a rotation, then drop the old one.
Custom headers are applied after ByeLabel's own headers, so a custom header named X-Webhook-Signature, X-Webhook-Id or Content-Type will replace the real value and break verification. Use your own names — X-My-Api-Key, X-Tenant — and you'll be fine.
A delivery is always a POST with a JSON body:
POST /hooks/byelabel HTTP/1.1
Host: your-server.com
Content-Type: application/json
User-Agent: ByeLabel-Webhook/1.0
X-Webhook-Event: order.created
X-Webhook-Id: 3c9a71e4-8b0d-4f52-a6c1-9e2d4b7f8a03
X-Webhook-Signature: 6f0c1a7d9b3e4c2f8a51d0e6b47c93f2a8d15e0b7c46a9f38d2e150c7b4a63f9
X-Webhook-Timestamp: 2026-09-10T09:20:31.204Z
X-Webhook-Attempt: 1
{"event":"order.created","timestamp":"2026-09-10T09:20:31.204Z","payload":{ … }}| Header | What it's for |
|---|---|
X-Webhook-Event | The event name — usually what you switch on. |
X-Webhook-Id | Unique id for this delivery. Stays the same across retries — use it as your idempotency key. |
X-Webhook-Signature | Proof the request came from ByeLabel. See §3. |
X-Webhook-Timestamp | When the event occurred (ISO-8601). Also identical across retries. |
X-Webhook-Attempt | Attempt number, starting at 1. |
User-Agent | Always ByeLabel-Webhook/1.0. |
The body always has exactly these three keys:
{
"event": "order.created", // same as the X-Webhook-Event header
"timestamp": "2026-09-10T09:20:31.204Z", // when it happened
"payload": { } // the data — varies per event, may be null
}payload is the only part that changes between events. See the event reference for what each one carries.
Always verify. Your endpoint is a public URL — without this check, anyone who finds it can post fake orders into your system.
X-Webhook-Signature is an HMAC-SHA256 of the raw request body, keyed by your signing secret, hex-encoded.
Three rules:
whsec_ prefix.timingSafeEqual / hash_equals / compare_digest), not with ==.const crypto = require('node:crypto');
const express = require('express');
const app = express();
const SECRET = process.env.BYELABEL_WEBHOOK_SECRET; // "whsec_…"
// capture the raw body — express.json() alone discards it
app.use('/hooks/byelabel', express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}));
app.post('/hooks/byelabel', (req, res) => {
const expected = crypto.createHmac('sha256', SECRET).update(req.rawBody).digest('hex');
const received = String(req.get('X-Webhook-Signature') || '');
const valid = received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.status(401).send('invalid signature');
// acknowledge immediately, do the real work afterwards
res.status(200).send('ok');
enqueue(req.get('X-Webhook-Id'), req.body);
});import hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["BYELABEL_WEBHOOK_SECRET"].encode()
@app.post("/hooks/byelabel")
def byelabel_hook():
expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
received = request.headers.get("X-Webhook-Signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
enqueue(request.headers["X-Webhook-Id"], request.get_json())
return "", 200$body = file_get_contents('php://input'); // raw, before json_decode
$expected = hash_hmac('sha256', $body, $secret);
$received = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, request.body.read)
received = request.env['HTTP_X_WEBHOOK_SIGNATURE'].to_s
halt 401 unless Rack::Utils.secure_compare(expected, received)| Your response | What ByeLabel does |
|---|---|
Any 2xx (200, 201, 204…) | Marks the delivery successful. Done. |
Any other status (4xx, 5xx) | Marks it failed and retries. |
| No response within 10 seconds | Same as a failure, logged as a timeout. |
| Response body over 1 MB | Treated as a failure. Keep responses small. |
Nothing about your response body is inspected — an empty 200 is the ideal reply. That said, the body is stored in your delivery log (first 4000 characters), so a short error message is useful to whoever debugs it later.
The 10-second budget covers DNS, TLS, your framework and your handler. If you validate the order, call your ERP and write to three tables before replying, you will eventually time out — and ByeLabel will retry an event you already processed.
The reliable pattern is:
200 immediately.Return a non-2xx only when you genuinely want the delivery retried — for example your database is down. Returning 500 because an order failed your business validation just buys you the same rejected order five more times.
A failed delivery is retried with exponential backoff:
| Attempt | Sent after |
|---|---|
| 1 | immediately |
| 2 | ~5 seconds |
| 3 | ~10 seconds |
| 4 | ~20 seconds |
| 5 | ~40 seconds |
| 6 | ~80 seconds |
Total attempts are max retries + 1 — six by default. After the last one the delivery is marked failed permanently; it is not retried again, and it's up to you to backfill via the REST API.
Deliver-at-least-once is the guarantee. You can receive the same event twice — most often when your server processed a request but answered too slowly, so ByeLabel counted it as a failure and retried.
Deduplicate on X-Webhook-Id, which is stable across retries:
if (await seen(webhookId)) return res.status(200).send('duplicate');
await markSeen(webhookId);Making your handler idempotent (upsert rather than insert) is worth doing regardless.
Deliveries are processed concurrently and retried independently, so order.updated can arrive before the order.created it followed. Use the timestamp field, or the timestamps inside the payload, to work out the real sequence — and re-fetch the record from the REST API when you need the authoritative current state.
Read this before you write your parser. payload is not always the full object — several events send only ids, and a couple send different shapes depending on what caused them. The safe pattern is to read the id from the payload and fetch the current record from the REST API when you need complete data.| Event | payload |
|---|---|
order.created | The full order object. |
order.updated | Usually the full order object — but when the change came from a carrier tracking update it is only { id, status }. |
order.deleted | An array of order id strings: ["9f2c…", "3a71…"] |
| Event | payload |
|---|---|
shipment.created | The full shipment, including label data. |
shipment.updated | The shipment record (typically a tracking status change). |
shipment.deleted | { id, carrier: { code, name }, tracking_number } — sent when a label is voided. |
| Event | payload |
|---|---|
product.created | The full product. |
product.updated | The full product. |
product.deleted | An array of product id strings. |
| Event | payload |
|---|---|
customer.created | The full customer. |
customer.updated | The full customer. |
customer.deleted | { id } — one event per deleted customer. |
| Event | payload |
|---|---|
batch.deleted | An array of batch id strings. |
| Event | payload |
|---|---|
store.synced | { store_id, last_sync } — a marketplace sync finished. |
store.deactivated | { store_id } — a store connection was turned off. |
| Event | payload |
|---|---|
workspace.updated | The workspace profile. |
workspace.deleted | null — no payload is sent. |
user.created | The user object. |
user.updated | The user object. |
user.deleted | { id: ["…", "…"] } — note id holds an array. |
| Event | payload |
|---|---|
balance | [{ currency, amount }] — your balance changed. |
Amounts here are in minor units (cents), unlike GET /v1/balance in the REST API, which returns major units. Divide by 100 before displaying.| Event | payload |
|---|---|
ticket.created | The full ticket. |
ticket.updated | { id, subject?, priority?, messages: [] } — only the fields that changed. |
ticket.replied | { id, messages: [ the new message ] } |
ticket.closed | { id, messages: [ the closing message ] } |
order.* matches every order. event. Deeper patterns like order.item.* are not reliably matched — use order.* or the exact event name. Every event above has at most two parts, so this only matters if you're guessing at future names.
New events may be added over time. If you subscribe to *, be sure your handler ignores event names it doesn't recognise instead of erroring.
Open Settings → Webhooks, pick an endpoint, and you get:
Overview — your signing secret, and a performance chart of deliveries, failures and average response time over the last 7 days (up to 90).
Deliveries — every attempt, newest first:
| Column | Meaning |
|---|---|
| Event | Which event was sent. |
| Status | success, failed, or pending (queued, not yet attempted). |
| Attempt | Which try this was. Several rows with the same event and rising attempt numbers is one delivery being retried. |
| Response | The HTTP status you returned. Empty means the request never completed — a timeout, DNS failure or TLS error. |
| Duration | How long your server took, in milliseconds. A duration of ~10000 with no status is a timeout. |
| Error | HTTP 502 for a bad status, or the transport error message. |
Send test event fires a delivery on demand so you can check your endpoint end to end without waiting for real activity.
| Symptom | Likely cause |
|---|---|
| No requests arriving at all | Endpoint disabled; the event isn't in its list; or the events you expect aren't being emitted. Use Send test event to isolate which. |
| Signature never matches | You're hashing the parsed-and-re-serialised body instead of the raw bytes. This is by far the most common cause — see §3. |
| Signature broke suddenly | Someone rotated the secret, or a custom header is overwriting X-Webhook-Signature. |
| Deliveries show ~10000 ms and no status | Your handler is timing out. Acknowledge first, process afterwards. |
| Same event processed twice | Expected behaviour — deduplicate on X-Webhook-Id. |
Delivery log shows 403 / 401 | Your server rejected the request: a WAF, IP allowlist, or auth middleware sitting in front of the webhook route. Webhook requests carry no session or bearer token — the signature is the authentication. |
payload missing fields you expected | Some events send only ids or partial objects. Check the event reference and re-fetch via the REST API. |
| Works locally, fails in production | A self-signed or expired TLS certificate, or a URL that isn't reachable from the public internet. |
| Response timeout | 10 seconds |
| Maximum response size | 1 MB |
| Attempts per delivery | max retries + 1 (default 6, maximum 21) |
| Retry backoff | ~5s, 10s, 20s, 40s, 80s |
| Events per endpoint | at least 1, no upper limit |
| Custom headers | up to 20, values up to 2000 characters |
| URL length | 500 characters |
| Stored response body | first 4000 characters |
| Delivery history | roughly 30 days |
200 before any real processing.X-Webhook-Id.