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 signaturev1=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.