Skip to main content
Your agent kicks off a job that takes 8 minutes. The caller has two bad options: hold the HTTP connection open for 8 minutes (and watch it die to a proxy timeout at minute 6), or poll tasks/get every few seconds (and discover that 95% of those calls return “still working”). Webhooks fix it. The caller registers a URL once, then goes about their day. When the task transitions — working, input-required, completed, failed, and so on — Bindu POSTs a signed JSON event to that URL. One notification per real event. Zero wasted requests. This follows the A2A Protocol push notification spec, so any A2A-compliant client can subscribe without custom code.
Use webhooks when a task may outlive a normal request timeout — minutes, hours, or days. For sub-second responses, just keep the connection open and skip this entirely.

Push vs polling

Polling (tasks/get)

Client decides cadence. Wastes requests. Latency = poll interval. Works everywhere, no inbound port required on the client.

Push (this doc)

Server decides cadence. One event per real state change. Sub-second latency. Client must expose an HTTPS endpoint Bindu can reach.

How it works

Per-task or global

Register a webhook per task at message/send, or set one global_webhook_url on the manifest that catches everything else.

Persistent

When the caller sets long_running=true, the webhook config is written to storage and reloaded on startup, so notifications survive restarts.

SSRF-hardened

Bindu resolves the webhook hostname once, refuses private / loopback / metadata IPs, and connects directly to the resolved address — no DNS-rebinding window between validation and delivery.
1

Subscribe

Send push_notification_config inline with message/send, or call tasks/pushNotificationConfig/set later. Add long_running: true if you need the subscription to survive a Bindu restart.
2

Execute

ManifestWorker runs the task. Every state transition and every artifact flush hits PushNotificationManager.
3

Deliver

NotificationService.send_event validates the URL, JSON-encodes the event (default=str for UUIDs/datetimes), POSTs with Authorization: Bearer <token>, and retries transient failures up to 3 attempts with exponential backoff.

Quick start

1. Declare the capability

WEBHOOK_URL and WEBHOOK_TOKEN environment variables auto-populate global_webhook_url / global_webhook_token when push_notifications is enabled, so you can keep secrets out of code.

2. Send a task with a webhook

3. Receive events

If capabilities.push_notifications is missing or False, every push RPC returns JSON-RPC error -32005 (PushNotificationNotSupportedError) and no events fire. Enable the capability first.

Events Bindu actually emits

Bindu does not emit a submitted event. submitted is the initial database state set when a task is accepted; the first webhook you ever receive is working. Every event is a JSON-RPC-free POST body — no envelope, just the event object.

Common envelope

Every event carries the same top-level fields:
  • event_id — unique per emission; use it to deduplicate on the receiver.
  • sequence — monotonically increasing per task, starting at 1. Use to detect out-of-order delivery.
  • timestamp — ISO 8601, UTC, microsecond precision.
Emitted when the worker picks the task up and transitions out of submitted.
Emitted when the agent needs more from the user before it can continue. The agent’s prompt is embedded inside status.message as an A2A Message so operator-facing clients (like the Bindu inbox) can show it directly.
Same shape applies for auth-required and any future intermediate state that passes a status_message through.
Emitted for each artifact the task produces, fired after the task has been persisted to storage (outbox pattern — the DB write commits before the notification leaves, so the webhook never references state that isn’t yet durable).
JSON encoding uses json.dumps(..., default=str), so UUIDs and datetimes inside the artifact serialize as strings — your receiver should treat artifact_id as a string, not a UUID type.
Terminal events. final: true tells the receiver no more events are coming for this task_id.
Order is: all artifact-update events first (for completed), then the terminal status-update.
States that can appear in a status-update: working, input-required, auth-required, completed, failed, canceled, rejected. Bindu also supports extended states (payment-required, negotiation-bid-submitted, etc.) — these emit the same envelope when the worker transitions to them.

Headers sent on every POST

That’s it. There is no HMAC signature header today — authentication is the bearer token. Compare it constant-time on receive.
Bindu does not sign payloads with an HMAC. The bearer token is the only authentication signal. If you need stronger integrity, mint a fresh token per task and rotate aggressively, or terminate the webhook behind a gateway that adds its own signing.

Registration paths

Inline (recommended)

Send push_notification_config in the message/send configuration. The subscription exists before the task starts, so no working event can race past you.

RPC after the fact

tasks/pushNotificationConfig/set — useful for late-binding a webhook to an existing task, or rotating the URL/token mid-flight.
Only the task owner (the DID that originally submitted the task) may call this. A non-owner gets TaskNotFound — Bindu does not leak that the task exists.

Persistence and fallback

Long-running tasks

When the caller sets long_running: true, PushNotificationManager calls storage.save_webhook_config(task_id, config). On boot, initialize() calls storage.load_all_webhook_configs() and reinstalls every subscription before the worker pool starts accepting tasks.
If long_running is omitted or false, the subscription lives in memory only. A restart silently drops it and the caller’s webhook goes quiet.

Webhook precedence

get_effective_webhook_config(task_id) resolves in this order:
  1. Task-specific config registered for task_id
  2. Manifest global_webhook_url (if set)
  3. None — no delivery, event is dropped silently

API reference

RPC methods

All four methods enforce caller ownership. If caller_did does not match the task owner stored at submission, the response is the same as a missing task — Bindu refuses to leak existence.

PushNotificationConfig


Delivery, retries, and failure handling

NotificationService lives in bindu/utils/notifications.py and handles every outbound POST. It is not Kafka, not SNS, not a queue — it is a direct HTTP call wrapped in the unified retry decorator.
There is no dead-letter queue. If your endpoint is down for longer than the retry window (~10 s with backoff), the event is dropped. Treat webhook delivery as best-effort and reconcile with tasks/get on reconnect for anything you cannot afford to miss.

Why 4xx is dropped

A 4xx means “your request is broken” — replaying it will fail the same way. Bindu logs at WARNING and moves on rather than burning retry budget. 429 is the exception: it means “slow down,” so it gets the full retry treatment.

SSRF protection (server side, automatic)

Before every POST, validate_config does this:
  1. Parse the URL; require http or https scheme and a non-empty netloc.
  2. Resolve the hostname via socket.getaddrinfo once.
  3. Reject loopback / private / link-local / metadata addresses.
  4. Pass the resolved IP through to the connection layer. The HTTP client connects directly to that IP and uses the original hostname only for the TLS SNI / cert verification.
This closes the TOCTOU DNS-rebinding window where a malicious DNS server could return a public IP for validation and a private IP for the actual connection. You do not need to re-implement any of this on the client.

Receiver patterns

Verify the token (constant-time)

Dedupe by event_id, order by sequence

Node / Express receiver

Smoke-test with curl


Disabling

Drop push_notifications from capabilities (or set it to False) and all four RPCs return JSON-RPC error -32005 (PushNotificationNotSupportedError). No events fire. Callers should fall back to tasks/get. To disable just the global fallback while leaving per-task webhooks intact, unset global_webhook_url (and unset WEBHOOK_URL in the environment).

Troubleshooting

Confirm the capability flag is on:
Confirm the subscription is registered:
If get returns Push notification configuration not found for task., the inline push_notification_config in message/send was missing or the task completed and the manager has dropped the entry.
long_running: true is required for persistence. Without it the subscription is in-memory only.
Bindu sends exactly Authorization: Bearer <token> — token verbatim, no extra whitespace. The token you registered must match byte-for-byte. Compare with hmac.compare_digest, not ==.
validate_config rejects:
  • Non-http/https schemes
  • Missing hostname
  • Hostnames that resolve to loopback, private, link-local, or cloud-metadata IPs
Use a publicly resolvable hostname. For local dev, expose via a tunnel rather than pointing at 127.0.0.1 or 10.x.x.x.
Bindu emits one event per state change, but a 5xx-then-success retry can cause the same event_id to appear twice on the wire. Dedupe on event_id and you are safe.
artifact-update events fire before the terminal completed event by design. Within a single TCP-bound task_id, follow sequence to detect reordering caused by retries.

Retry policy

How the 3-attempt exponential backoff is wired and how to tune it.

Storage

Where long_running=true actually persists webhook configs.

Scheduler

The path between message/send and the worker that fires events.

A2A spec

The interop contract this implementation follows.
Sunflower LogoBindu turns long-running work intosomething clients can follow without polling, so tasks can keep running while updates keep moving.