Bjet24

Bjet24 webhooks: track every postal sending status in real time

Rather than hammering our API with polling, plug in a webhook: for each sending you receive transitions (created, posted, in delivery, delivered, return receipt). Here is the payload structure, signature verification and a copy-paste Next.js handler.

May 11, 20267 min read

Why a webhook (and not polling)

Once you have integrated the Bjet24 API to automate your mailings, the next question hits fast: how do you know where each item stands without hitting the API every 30 seconds?

Polling works, but it is expensive (API cost, latency, code to maintain) and imprecise. The clean answer: webhooks. You give us an HTTPS URL, we call you on every status transition with a signed payload. Your app stays passive and gets the info within a second.

The tracked events

Bjet24 exposes the following events for postal mailings:

  • mailing.created — the mailing was created via the API,
  • mailing.printed — the mailing has been printed in the print centre,
  • mailing.handed_over — the mailing has been handed over to bpost,
  • mailing.in_delivery — the mailing is on the postman's round,
  • mailing.delivered — the mailing has been delivered to the recipient,
  • mailing.return_receipt — the return receipt has been signed (registered only),
  • mailing.failed — delivery failed (delivery notice, refusal, return to sender).

Every event includes a mailing_id, a status, a UTC ISO 8601 timestamp and a free-form metadata object you provided at creation time (for example an internal invoice_id).

Configuring an endpoint

On the Bjet24 side: you declare an endpoint in the developer console or via the API. You attach to it:

  • a public HTTPS URL (TLS 1.2 minimum),
  • a secret that we use to sign every request,
  • the list of events you want to receive.

On your application side, the handler must:

  1. accept a JSON POST,
  2. verify the signature X-Bjet24-Signature,
  3. respond 2xx quickly (under 5 s),
  4. process the event idempotently (the event_id is unique).

Payload structure

{
  "event_id": "evt_01HYZ...",
  "event_type": "mailing.delivered",
  "occurred_at": "2026-05-11T08:42:13Z",
  "data": {
    "mailing_id": "ml_01HYX...",
    "status": "delivered",
    "tracking_number": "ZA123456789BE",
    "recipient": {
      "name": "ACME LLC",
      "address_line_1": "Rue de la Loi 16",
      "postal_code": "1000",
      "city": "Brussels"
    },
    "metadata": {
      "invoice_id": "INV-2026-0421"
    }
  }
}

The metadata field is an arbitrary JSON object passed at mailing creation. Use it to bridge with your internal identifiers without having to maintain a mapping table.

The HMAC signature

Every request contains an X-Bjet24-Signature header in the format t=TIMESTAMP,v1=SIGNATURE. The signature is an HMAC-SHA256 computed over the string TIMESTAMP.RAW_BODY with your secret.

Recommended verification:

  • reject requests whose timestamp drifts more than 5 minutes from the server clock (replay protection),
  • compare signatures in constant time (timingSafeEqual).

Example: Next.js handler (App Router)

// app/api/webhooks/bjet24/route.ts
import { createHmac, timingSafeEqual } from "node:crypto"
import { NextRequest } from "next/server"

const SECRET = process.env.BJET24_WEBHOOK_SECRET!

export async function POST(req: NextRequest) {
  const raw = await req.text()
  const header = req.headers.get("x-bjet24-signature") ?? ""

  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  )
  const ts = parts.t
  const sig = parts.v1
  if (!ts || !sig) return new Response("missing signature", { status: 400 })

  const tsNum = Number(ts)
  if (Math.abs(Date.now() / 1000 - tsNum) > 300) {
    return new Response("stale timestamp", { status: 400 })
  }

  const expected = createHmac("sha256", SECRET)
    .update(`${ts}.${raw}`)
    .digest("hex")

  const a = Buffer.from(expected, "hex")
  const b = Buffer.from(sig, "hex")
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response("invalid signature", { status: 401 })
  }

  const event = JSON.parse(raw) as {
    event_id: string
    event_type: string
    data: { mailing_id: string; status: string; metadata?: Record<string, string> }
  }

  // Idempotency: skip if event_id already processed.
  // Then switch on event.event_type.
  if (event.event_type === "mailing.delivered") {
    // ...update your invoice / customer file...
  }

  return new Response("ok", { status: 200 })
}

Retry policy

If your endpoint returns 5xx or fails to respond, we retry with exponential backoff for 24 hours (typically 6 to 8 retries). As long as you return 2xx, we treat the event as delivered.

Practical consequence: your handler must be idempotent. The same event_id can land more than once (e.g. you responded 2xx but the connection was cut right before we saw it). Store processed event_id values and ignore duplicates.

Three good practices

  • Log everything. Keep the raw payloads for at least a few days. Invaluable when debugging an unexpected status.
  • Decouple processing. Respond 200 immediately, then push the actual work to a queue (BullMQ, SQS, etc.). You avoid retry pile-ups.
  • Version your handler. When you change the shape of your business logic, deploy the new handler on a versioned URL and migrate progressively.

Summary

Wiring a Bjet24 webhook takes 30 minutes: a signed endpoint, an HMAC verification, an idempotent handler. In exchange, you get real-time visibility on every postal mailing sent from Belgium — no API debt, no cron to maintain. For businesses handling bulk mail campaigns via bpost, it is the simplest way to turn "the mail has been sent" into actionable information in your CRM or ERP.

Frequently asked questions

How do I verify the signature of a Bjet24 webhook?

Every request from Bjet24 includes an X-Bjet24-Signature header in the format t=TIMESTAMP,v1=SIGNATURE. Compute an HMAC-SHA256 over the string TIMESTAMP.RAW_BODY using your secret, then compare the result in constant time with timingSafeEqual. Reject any request whose timestamp drifts more than 5 minutes from your server clock to protect against replay attacks.

What happens if my webhook endpoint does not respond?

If your endpoint returns a 5xx status or fails to respond within the allowed window, Bjet24 retries automatically with exponential backoff for up to 24 hours, typically 6 to 8 attempts. Once your endpoint returns a 2xx code, the event is marked as delivered and retries stop. The recommended pattern is to respond 200 immediately and offload real processing to an async queue.

How do I avoid processing the same webhook event twice?

Each event carries a unique event_id. Persist processed identifiers in a database or Redis cache and silently skip events whose identifier is already present. This idempotency is essential because the same event can be delivered more than once if the connection drops between delivery and your 2xx response.

Which postal status events can I receive through webhooks?

Webhooks cover all key transitions of a postal mailing: creation via the API (mailing.created), printing (mailing.printed), handover to bpost (mailing.handed_over), entry into the delivery round (mailing.in_delivery), delivery to the recipient (mailing.delivered), return-receipt signature (mailing.return_receipt) and delivery failure (mailing.failed). You choose which events to subscribe to when declaring the endpoint.

Can I test a webhook endpoint locally before deploying to production?

Yes. Use a tunnelling tool such as ngrok or Cloudflare Tunnel to temporarily expose your local server through a public HTTPS URL. Register that URL in the developer console, trigger test mailings and inspect the received payloads. This lets you validate your signature verification logic and event handling without a production deployment.

Ready to send a letter?

Send a letter or registered mail in minutes — no printer, no post office.

How it works

Related articles

Comments (0)

Loading comments…

Leave a comment

Your comment will be published after moderator approval.