Webhooks
Receive real-time push notifications when HMRC accepts or rejects a filing.
How webhooks work
HMRC processes CT600 submissions asynchronously — a filing submitted at 14:30 may not be acknowledged until 14:32. Rather than polling GET /v1/filings/:id repeatedly, you can register a webhook endpoint and Render will POST an event to it the moment HMRC's acknowledgement arrives.
Render delivers webhooks over HTTPS. Your endpoint must return a 2xx response within 10 seconds. Failed deliveries are retried with exponential backoff.
Register an endpoint
Add your endpoint URL in the Render dashboard under Settings → Webhooks → Add endpoint. You can register separate endpoints for sandbox and live environments.
After adding an endpoint you'll receive a signing secret starting with whsec_. Store this securely — you'll use it to verify that events genuinely come from Render.
Event types
| Parameter | Type | Description |
|---|---|---|
filing.accepted | event | HMRC has accepted the submission. The filing status is now accepted. The correlation_id in the event data is HMRC's reference. |
filing.failed | event | HMRC has rejected the submission. The filing status is now failed. The hmrc_error field contains HMRC's original error text. |
filing.submitted | event | Render has successfully dispatched the GovTalk envelope to HMRC and received a CorrelationID. Awaiting HMRC acknowledgement. |
Event object
Every event has a consistent envelope. The data.object contains the full Filing object at the time the event fired.
{
"id": "evt_01j9y2...",
"type": "filing.accepted",
"created": "2025-11-05T14:32:07Z",
"data": {
"object": {
"id": "fil_01j9xkz7...",
"status": "accepted",
"correlation_id": "20251105T143021Z-GB-7b2f9...",
"period_start": "2024-01-01",
"period_end": "2024-12-31",
"company_utr": "1234567890",
"schema_version": "v1.993",
"created_at": "2025-11-05T14:30:21Z",
"updated_at": "2025-11-05T14:32:07Z"
}
}
}| Parameter | Type | Description |
|---|---|---|
id | string | Unique event ID. Safe to use for deduplication — Render guarantees at-least-once delivery. |
type | string | Event type string, e.g. filing.accepted. |
created | string | ISO 8601 timestamp when the event was created. |
data.object | Filing | The Filing object that triggered this event. |
Verify signatures
Render signs every webhook delivery with an HMAC-SHA256 signature using your webhook secret. Always verify this signature before processing an event.
The Render-Signature header has the format t=TIMESTAMP,v1=SIGNATURE. The signed payload is {timestamp}.{raw_body}.
import hashlib
import hmac
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_webhook_secret"
def verify_signature(payload: bytes, sig_header: str, secret: str) -> bool:
"""Verify the Render-Signature header."""
# Header format: "t=TIMESTAMP,v1=SIGNATURE"
parts = dict(item.split("=", 1) for item in sig_header.split(","))
timestamp = parts.get("t", "")
received_sig = parts.get("v1", "")
# Signed payload = timestamp + "." + raw body
signed_payload = f"{timestamp}.".encode() + payload
expected_sig = hmac.new(
secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_sig, received_sig)
@app.route("/webhooks/tunnel", methods=["POST"])
def handle_webhook():
payload = request.get_data()
sig = request.headers.get("Render-Signature", "")
if not verify_signature(payload, sig, WEBHOOK_SECRET):
abort(400, "Invalid signature")
event = request.json
if event["type"] == "filing.accepted":
filing = event["data"]["object"]
# Mark filing as accepted in your database
mark_filing_accepted(filing["id"], filing["correlation_id"])
elif event["type"] == "filing.failed":
filing = event["data"]["object"]
# Handle HMRC rejection
handle_rejection(filing["id"], filing.get("hmrc_error"))
return "", 200hmac.compare_digest in Python, crypto.timingSafeEqual in Node) when comparing signatures. Standard string equality is vulnerable to timing attacks.Retry logic
If your endpoint returns a non-2xx response or times out, Render retries with exponential backoff:
| Parameter | Type | Description |
|---|---|---|
Attempt 1 | | Immediately after failure |
Attempt 2 | | 5 minutes later |
Attempt 3 | | 30 minutes later |
Attempt 4 | | 2 hours later |
Attempt 5 | | 8 hours later (final) |
After 5 failed attempts the event is marked abandoned and visible in the dashboard under Webhooks → Failed events where you can manually replay it.
Best practices
Respond immediately, process asynchronously
Return 200 OK as quickly as possible — ideally before any database writes. Push the event to a background queue (Celery, BullMQ, SQS) and process it there. This prevents Render from timing out and retrying unnecessarily.
Deduplicate by event ID
Render guarantees at-least-once delivery, not exactly-once. Store processed event IDs and skip duplicates:
# Before processing, check if we've seen this event
if Event.objects.filter(tunnel_event_id=event["id"]).exists():
return "", 200 # Already handled
Event.objects.create(tunnel_event_id=event["id"])
# ... process the eventHandle old event versions gracefully
New fields may be added to event payloads over time. Write handlers that ignore unknown fields rather than raising on unexpected keys.