Why webhooks?

ATO submissions are asynchronous. When you POST /pay_events/:id/submit, we queue the lodgement job and respond immediately. The actual submission can take anywhere from seconds to minutes depending on ATO load. Without webhooks, you'd have to poll /submissions/:id until the status changes — wasteful and slow.

With webhooks, you give us a URL on your server and we POST to it whenever something changes. No polling. Your integration finds out about ATO acceptance the instant it happens.

Setting up an endpoint

From the web UI: Webhooks → Add endpoint.

Provide:

Field What it is
URL Your server endpoint. Must be HTTPS in production.
Environment test or live — controls which submissions fire events to this endpoint
Description Optional label for your own reference
Events Checkboxes for event types. Leave all unchecked to receive every event.

When you save, we generate a signing secret that's shown exactly once. Copy it immediately to your server's config.

Receiving a webhook

Each webhook is a POST with a JSON body and custom headers:

POST /webhook/beeswax_stp HTTP/1.1
Host: your-app.com
Content-Type: application/json
User-Agent: Beeswax-STP-Webhooks/1.0
Beeswax-Signature: t=1712659200,v1=5d41402abc4b2a76b9719d911017c592...
Beeswax-Event-Type: submission.accepted

{
  "id": "evt_abc123...",
  "type": "submission.accepted",
  "created": "2026-04-09T11:33:58Z",
  "data": {
    "submission": {
      "id": 501,
      "status": "accepted",
      "environment": "evte",
      "message_id": "ATO-MSG-12345",
      "ato_status": "accepted",
      "sent_at": "2026-04-09T11:33:41Z",
      "response_received_at": "2026-04-09T11:33:58Z",
      "error_messages": null
    },
    "pay_event": {
      "id": 300,
      "employer_id": 12,
      "pay_period_start": "2026-04-01",
      "pay_period_end": "2026-04-14",
      "payment_date": "2026-04-14",
      "total_gross": 4820.00,
      "total_tax": 1145.00,
      "employee_count": 1
    },
    "employer": {
      "id": 12,
      "name": "Acme Pty Ltd",
      "abn": "51824753556"
    }
  }
}

Verifying the signature

Always verify the signature before acting on a webhook. An attacker who knows your URL could POST arbitrary data otherwise.

We sign each webhook with HMAC-SHA256 over the raw request body plus a timestamp. The signature header looks like this:

Beeswax-Signature: t=1712659200,v1=5d41402abc4b2a76b9719d911017c592f3a8a8d5e7e3...
  • t= is the Unix timestamp when we generated the signature
  • v1= is the HMAC-SHA256 hex digest of "{timestamp}.{raw_body}" using your signing secret as the key

Verification in Ruby

require "openssl"

def verify_beeswax_webhook(request, signing_secret)
  header    = request.headers["Beeswax-Signature"].to_s
  parts     = header.split(",").map { |p| p.split("=", 2) }.to_h
  timestamp = parts["t"]
  received  = parts["v1"]

  return false unless timestamp && received

  # Reject payloads older than 5 minutes to prevent replay attacks
  return false if Time.now.to_i - timestamp.to_i > 300

  signed   = "#{timestamp}.#{request.raw_post}"
  expected = OpenSSL::HMAC.hexdigest("SHA256", signing_secret, signed)

  ActiveSupport::SecurityUtils.secure_compare(expected, received)
end

Verification in Node.js

const crypto = require("crypto");

function verifyBeeswaxWebhook(rawBody, header, signingSecret) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  const { t: timestamp, v1: received } = parts;

  if (!timestamp || !received) return false;

  // Reject old payloads (replay protection)
  if (Math.floor(Date.now() / 1000) - Number(timestamp) > 300) return false;

  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

Verification in Python

import hmac, hashlib, time

def verify_beeswax_webhook(raw_body: bytes, header: str, signing_secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = parts.get("t")
    received  = parts.get("v1")

    if not timestamp or not received:
        return False

    # Reject old payloads
    if int(time.time()) - int(timestamp) > 300:
        return False

    signed = f"{timestamp}.{raw_body.decode('utf-8')}".encode()
    expected = hmac.new(signing_secret.encode(), signed, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, received)

Use the raw, unparsed request body. If you reconstruct the JSON from your framework's parsed params, you'll get a different signature. Most frameworks expose the raw body as request.raw_post, req.rawBody, await req.text(), etc.

Responding to a webhook

Respond with any 2xx status code. We treat anything in the 200–299 range as success. Body content is ignored — a bare 200 OK is fine.

def beeswax_webhook
  raw   = request.raw_post
  sig   = request.headers["Beeswax-Signature"]

  unless verify_beeswax_webhook(request, ENV["BEESWAX_SIGNING_SECRET"])
    head :unauthorized
    return
  end

  event = JSON.parse(raw)
  EventProcessor.enqueue(event)  # don't block the response — queue it

  head :ok
end

Respond fast

Aim to acknowledge within 2 seconds. If your handler is slow, we'll retry — and the customer's ATO status will look stuck from your app's perspective. Queue the event to a background job and return 200 OK immediately.

Our HTTP client has a 10-second timeout. Exceeding that counts as a failure.

Retries and backoff

If we don't get a 2xx response, we retry with exponential backoff:

Attempt Delay
1 Immediate
2 1 minute
3 5 minutes
4 25 minutes
5 2 hours
6 10 hours
7 1 day
8 2 days

After 8 failed attempts the delivery is marked abandoned and we stop trying. After 10 consecutive failures across different events, the endpoint is auto-paused and no more events are sent until you reactivate it from the UI.

You can see delivery history, including retry timing and the exact HTTP responses we received, on the endpoint detail page.

Event types

Event type When
submission.pending A submission has been created and queued for processing
submission.sent The XBRL has been signed and handed off to the ATO gateway
submission.accepted The ATO has confirmed acceptance — your pay event is lodged
submission.rejected The ATO has rejected the submission with an error (check error_messages in the payload)
submission.error Something went wrong on our side (signing failure, network error)
pay_event.created A new pay event was created via API or UI
pay_event.updated An existing pay event was modified

New event types will be added over time. Your handler should ignore unknown event types rather than erroring on them — we won't send you anything destructive, but forward-compatibility matters.

Environment isolation

Webhook endpoints are scoped to either test or live. Submissions in EVTE only fire to test endpoints; production submissions only fire to live endpoints. This prevents test traffic from waking up your production monitoring, and prevents production traffic from polluting your dev logs.

Replay protection

Include a timestamp check in your verification (see code examples above) and reject payloads older than 5 minutes. We sign every webhook with the current timestamp; an attacker who captures a legitimate webhook can't replay it later because your check will fail.

For extra paranoia, you can also keep a short-lived cache of event.id values you've already processed and reject duplicates. We don't send duplicates under normal operation, but it's cheap insurance against bugs.

Event archive and replay

Every delivery we attempt is persisted for 90 days and can be inspected or re-delivered via the Events API. This is the recommended recovery path when your server is down for a maintenance window or a handler bug drops events on the floor.

List recent events

GET /api/v1/events?event_type=submission.accepted&limit=50

Cursor-paginated. Pass starting_after=evt_xxx with the last event ID from the previous page to walk backwards through history.

Response:

{
  "data": [
    {
      "id": "evt_4f1c2b...",
      "event_type": "submission.accepted",
      "status": "succeeded",
      "attempts": 1,
      "created_at": "2026-04-16T02:11:04Z",
      "delivered_at": "2026-04-16T02:11:05Z",
      "webhook_endpoint_id": 42
    }
  ],
  "has_more": true,
  "next_cursor": "evt_4f1c2b..."
}

Inspect a single event

GET /api/v1/events/evt_4f1c2b...

Returns the full payload plus the last HTTP response code/body we received from your endpoint and any error message — useful when you're chasing why a specific delivery didn't land.

Resend an event

POST /api/v1/events/evt_4f1c2b.../resend

Queues a fresh delivery of the exact same payload. The replay gets its own evt_... ID and carries replayed_from_id pointing back at the original — so your handler can log replays distinctly if it cares. Safe to call more than once (idempotent by Idempotency-Key).

Why resend instead of reactivate? Reactivating a paused endpoint starts delivering new events. resend lets you replay specific past events you know you missed.

Rotating the signing secret

From the endpoint detail page, click Rotate secret. A new secret is generated and shown once. The old secret is invalidated instantly — make sure your server is ready to accept the new one before clicking rotate.

Troubleshooting

"I'm seeing 401 from your verification code"
Check that you're using the raw body, not your framework's parsed JSON. Most web frameworks mutate the body when they parse it — rebuilding the JSON from the parsed hash will produce a different signature.

"Deliveries keep failing"
Check the endpoint detail page — we show the exact response code and error message for each delivery. Common causes:
- Endpoint is HTTP instead of HTTPS (production rejects this)
- TLS cert problems on your end
- Your server is returning 5xx
- Your server is slow (>10s timeout)

"I want to test webhooks locally"
Use a tunnel service like ngrok or webhook.site to expose your localhost to the internet. Create a test endpoint pointing to the tunnel URL, run through the getting-started flow with a test token, and watch the requests arrive.

"I'm getting duplicate deliveries"
We retry failed deliveries, so if your handler takes longer than 10 seconds you might see duplicates when it eventually succeeds. Either respond faster or deduplicate on event.id in your handler.