ByeLabel Webhooks

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 OK

Contents

  1. Setting up an endpoint
  2. What ByeLabel sends
  3. Verifying the signature
  4. How to respond
  5. Retries, duplicates and ordering
  6. Event reference
  7. Monitoring deliveries
  8. Troubleshooting
  9. Limits
  10. Go-live checklist

1. Setting up an endpoint

Webhook 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:

FieldNotes
NameAnything that helps you recognise it later, e.g. "ERP bridge (production)". Required, up to 150 characters.
URLWhere to send the requests. Must be http:// or https://, up to 500 characters. Use HTTPS in production.
EventsWhich events to receive — at least one. See the event reference.
EnabledTurn delivery on or off without deleting the endpoint.
Max retriesHow many times to retry a failed delivery. Default 5, maximum 20.
Custom headersOptional 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.

Choosing events

You can subscribe to exact names or use wildcards:

You subscribe toYou receive
order.createdjust 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.

Your signing secret

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 — one caveat

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.


2. What ByeLabel sends

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":{ … }}

Headers

HeaderWhat it's for
X-Webhook-EventThe event name — usually what you switch on.
X-Webhook-IdUnique id for this delivery. Stays the same across retries — use it as your idempotency key.
X-Webhook-SignatureProof the request came from ByeLabel. See §3.
X-Webhook-TimestampWhen the event occurred (ISO-8601). Also identical across retries.
X-Webhook-AttemptAttempt number, starting at 1.
User-AgentAlways ByeLabel-Webhook/1.0.

Body

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.


3. Verifying the signature

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:

  1. Hash the raw bytes you received, before parsing the JSON. Re-serialising the parsed object changes key order and whitespace, and the signature won't match.
  2. Use the full secret including the whsec_ prefix.
  3. Compare in constant time (timingSafeEqual / hash_equals / compare_digest), not with ==.

Node.js / Express

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);
});

Python / Flask

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

PHP

$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);

Ruby

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)

4. How to respond

Your responseWhat 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 secondsSame as a failure, logged as a timeout.
Response body over 1 MBTreated 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.

Answer first, work second

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:

  1. Verify the signature.
  2. Push the event onto a queue (or insert it into a table).
  3. Return 200 immediately.
  4. Process it in a background worker.

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.


5. Retries, duplicates and ordering

Retries

A failed delivery is retried with exponential backoff:

AttemptSent after
1immediately
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.

Expect duplicates

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.

Don't rely on ordering

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.


6. Event reference

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.

Orders

Eventpayload
order.createdThe full order object.
order.updatedUsually the full order object — but when the change came from a carrier tracking update it is only { id, status }.
order.deletedAn array of order id strings: ["9f2c…", "3a71…"]

Shipments

Eventpayload
shipment.createdThe full shipment, including label data.
shipment.updatedThe shipment record (typically a tracking status change).
shipment.deleted{ id, carrier: { code, name }, tracking_number } — sent when a label is voided.

Products

Eventpayload
product.createdThe full product.
product.updatedThe full product.
product.deletedAn array of product id strings.

Customers

Eventpayload
customer.createdThe full customer.
customer.updatedThe full customer.
customer.deleted{ id } — one event per deleted customer.

Batches

Eventpayload
batch.deletedAn array of batch id strings.

Stores

Eventpayload
store.synced{ store_id, last_sync } — a marketplace sync finished.
store.deactivated{ store_id } — a store connection was turned off.

Workspace and users

Eventpayload
workspace.updatedThe workspace profile.
workspace.deletednull — no payload is sent.
user.createdThe user object.
user.updatedThe user object.
user.deleted{ id: ["…", "…"] } — note id holds an array.

Billing

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

Support tickets

Eventpayload
ticket.createdThe 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 ] }

A note on wildcards

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.


7. Monitoring deliveries

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:

ColumnMeaning
EventWhich event was sent.
Statussuccess, failed, or pending (queued, not yet attempted).
AttemptWhich try this was. Several rows with the same event and rising attempt numbers is one delivery being retried.
ResponseThe HTTP status you returned. Empty means the request never completed — a timeout, DNS failure or TLS error.
DurationHow long your server took, in milliseconds. A duration of ~10000 with no status is a timeout.
ErrorHTTP 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.


8. Troubleshooting

SymptomLikely cause
No requests arriving at allEndpoint 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 matchesYou'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 suddenlySomeone rotated the secret, or a custom header is overwriting X-Webhook-Signature.
Deliveries show ~10000 ms and no statusYour handler is timing out. Acknowledge first, process afterwards.
Same event processed twiceExpected behaviour — deduplicate on X-Webhook-Id.
Delivery log shows 403 / 401Your 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 expectedSome events send only ids or partial objects. Check the event reference and re-fetch via the REST API.
Works locally, fails in productionA self-signed or expired TLS certificate, or a URL that isn't reachable from the public internet.

9. Limits

Response timeout10 seconds
Maximum response size1 MB
Attempts per deliverymax retries + 1 (default 6, maximum 21)
Retry backoff~5s, 10s, 20s, 40s, 80s
Events per endpointat least 1, no upper limit
Custom headersup to 20, values up to 2000 characters
URL length500 characters
Stored response bodyfirst 4000 characters
Delivery historyroughly 30 days

10. Go-live checklist

  • Endpoint is HTTPS with a valid certificate, reachable from the public internet.
  • Signature verified on every request, against the raw body, in constant time.
  • The secret lives in configuration — not in source control, not in client-side code.
  • Requests are acknowledged with 200 before any real processing.
  • Handler deduplicates on X-Webhook-Id.
  • Handler tolerates events arriving out of order.
  • Unknown event names are ignored rather than treated as errors.
  • No WAF, IP allowlist or auth middleware is blocking the route.
  • Tested end to end with Send test event.
  • Someone is watching the Deliveries tab (or your own alerting) for a rising failure rate.