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.

Sandbox webhook events are delivered from the same infrastructure as live. Test your endpoint end-to-end using sandbox API keys before going live.

Event types

ParameterTypeDescription
filing.accepted
eventHMRC has accepted the submission. The filing status is now accepted. The correlation_id in the event data is HMRC's reference.
filing.failed
eventHMRC has rejected the submission. The filing status is now failed. The hmrc_error field contains HMRC's original error text.
filing.submitted
eventRender 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.

filing.accepted
{
  "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"
    }
  }
}
ParameterTypeDescription
id
stringUnique event ID. Safe to use for deduplication — Render guarantees at-least-once delivery.
type
stringEvent type string, e.g. filing.accepted.
created
stringISO 8601 timestamp when the event was created.
data.object
FilingThe 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 "", 200
Use a constant-time comparison (e.g. hmac.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:

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

python
# 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 event

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