Hosted Agent — Webhook & Callback Contract
Everything a Hosted agent must implement to receive buyer config, report that it's running, and ask the buyer to fix a bad credential.
Want this built or checked automatically? Build & Verify Your Agent with AI packages this contract into copy-paste prompts for an AI assistant.
All callbacks authenticate with an HMAC-SHA256 of `${subscriptionId}.${ts}`
signed with your listing's webhookSecret, sent as:
X-AgentDukaan-Sig— the hex HMACX-AgentDukaan-Ts—ts(epoch ms; rejected if older than 5 minutes)
const ts = Date.now()
const sig = crypto.createHmac('sha256', WEBHOOK_SECRET)
.update(`${subscriptionId}.${ts}`).digest('hex')
Key custody — pull model. We never push the buyer's secrets (API keys etc.) to your endpoint. The
buyer_configuredwebhook is a notification that carries only non-sensitive settings; you fetch the real credentials on demand from the signed config endpoint (step 2) and hold them in memory only — do not write them to your database or logs.
1. Get notified — buyer_configured (we → you)
When a buyer finishes (or re-submits) setup, we POST to your listing's
webhookEndpoint. The body is signed: X-AgentDukaan-Sig is the HMAC of the
raw JSON body with your webhookSecret; verify it before trusting the data.
{
"event": "buyer_configured",
"subscriptionId": "…",
"buyerId": "…",
"listingId": "…",
"config": { "waba_phone_id": "…" }, // NON-sensitive settings only
"sensitiveFields": ["openai_api_key"], // ids you must fetch (step 2)
"credentialsUrl": "https://api.agentdukaan.in/api/subscriptions/…/config",
"tier": { "name": "Pro", "priceINR": 1999, "runLimitPerMonth": 5000 },
"ts": 1718600000000
}
On receipt: fetch the credentials (step 2), cache them in memory against
subscriptionId, and start serving that buyer. Re-fetch when you receive another
buyer_configured (the buyer rotated/fixed a key).
Delivery is retried 3× with backoff. The buyer's dashboard shows Notifying provider… → ✓ / Provider not reached based on your 2xx response, so return 2xx only once you've processed it.
2. Fetch credentials — GET /api/subscriptions/:id/config (you → us)
Returns the buyer's decrypted config, including the sensitive fields. Same
HMAC auth as /run; the signature's 5-minute window makes each request a
short-lived token.
// GET, headers: X-AgentDukaan-Sig, X-AgentDukaan-Ts (HMAC of `${id}.${ts}`)
// → 200
{ "success": true, "data": { "config": { "openai_api_key": "sk-…", "waba_phone_id": "…" } } }
Hold the result in memory only. Don't persist it — fetch on buyer_configured
and cache for the process lifetime; re-fetch on the next event. Every fetch is
recorded in our audit log.
Active subscriptions only.
/config,/run, and/heartbeatreturn403 SUBSCRIPTION_NOT_ACTIVEonce the buyer's subscription is cancelled, paused, or past-due. When you see it, stop serving that buyer and discard any cached credentials — entitlement (and credential access) ends with billing.
3. Report a run — POST /api/subscriptions/:id/run (you → us)
Call this each time you do billable work for a buyer. It enforces the tier's
runLimitPerMonth and stamps firstRunAt/lastRunAt (the dashboard's
✓ Running / Awaiting first run badge).
429 RUN_LIMIT_EXCEEDED → the buyer is over their monthly limit; stop serving
until the period resets.
4. Heartbeat — POST /api/subscriptions/:id/heartbeat (you → us)
Optional liveness ping that does not consume a run. Use it (e.g. hourly) so we can tell "configured but idle" from "configured and healthy".
5. Ask the buyer to fix a credential — POST /api/subscriptions/:id/request-config
When a key is wrong at runtime (e.g. the buyer's OpenAI key returns 401), tell us which wizard field ids to re-collect. We flag the subscription Action needed, notify the buyer, and reopen just those fields.
⚠️ This endpoint signs differently from the others. Because it pushes a buyer-facing prompt, the reusable
`${subscriptionId}.${ts}`run token is not accepted here — that would let a captured run signature trigger an arbitrary prompt to the buyer. Sign a payload that also binds the sorted field ids and the message:
const ts = Date.now()
const fields = ['openai_api_key']
const message = 'Your OpenAI key returned 401.'
const payload = [
'config_request',
subscriptionId,
ts,
[...fields].sort().join(','), // field ids, sorted, comma-joined
message ?? '',
].join('.')
const sig = crypto.createHmac('sha256', WEBHOOK_SECRET).update(payload).digest('hex')
// POST headers: X-AgentDukaan-Sig: sig, X-AgentDukaan-Ts: ts
{ "fields": ["openai_api_key"], "message": "Your OpenAI key returned 401." }
The fields and message you send in the body must match what you signed
(same ids, same message) or you'll get 401. When the buyer re-submits, you
receive a fresh buyer_configured (step 1) with the corrected config. A
seller can also trigger this manually from a logged-in session instead of via
HMAC.
Note: setup also validates known providers (OpenAI, Anthropic, Telegram, Razorpay) at submit time when
VALIDATE_CREDENTIALS=true(always on in production), so most bad keys are caught before they ever reach your agent.
6. In-app chat — replies, idempotency, push & state
Agents that talk to the buyer over the in-app chat channel receive each buyer message as a relay webhook and answer over the chat reply endpoint with the rich ChatBlock kit. The reply endpoint and its HMAC scheme are unchanged; the additions below make replies idempotent and let an agent push and remember state.
6.1 Idempotency — replyTo and replace
Each inbound relay carries a messageId. Echo it back as replyTo in your
reply body. The platform uses replyTo to dedup a retried reply — if a
network blip makes you POST the same reply twice, it collapses to one bubble
instead of two.
Set replace: true to edit a prior bubble in place instead of appending a new
one — e.g. swap a "working…" placeholder for the finished result.
// reply body
{ "text": "Done ✓", "blocks": [ … ], "replyTo": "<inbound messageId>", "replace": false }
Both fields are optional and additive; an agent that ignores them keeps working.
6.2 New inbound fields — platform and state
Every inbound relay payload now also carries:
platform— capability negotiation:{ capsVersion, blocks, actions, proactive }.blocks/actionsare the block types and platform-actions your manifest declared intersected with what this renderer supports;capsVersionis the renderer's current ceiling;proactivesays whether push is enabled. Read it to emit only blocks the buyer can actually see instead of guessing.state— the durable per-conversation blob you last saved (§6.4), if any.
Both are additive — older agents that ignore them are unaffected.
6.3 Proactive push — message the buyer without a turn
POST /api/subscriptions/:id/chat/push lets your agent send the buyer a message
without a buyer turn (a down/recovered alert, "your report is ready", a
nudge).
// body
{
"text": "Your site is back up ✓",
"blocks": [ … ], // optional ChatBlock objects
"note": "optional footer",
"clientMsgId": "site-up-2026-06-20", // REQUIRED idempotency key — a re-POST is a no-op
"category": "alert" // "alert" | "report" | "nudge" | "transactional"
}
Push is opt-in: declare it in your manifest's capabilities —
"capabilities": { "proactive": { "enabled": true, "maxPerDay": 20, "categories": ["alert"] } }
Without that, the endpoint returns 403. A server-enforced per-day quota
(maxPerDay, default 10) and a per-conversation buyer mute are the
counterweights: an over-quota push gets 429, and a conversation the buyer has
muted returns 202 as a silent no-op (treat it as delivered — don't retry).
Push signs its own HMAC domain (not the run token, not chat_reply):
const ts = Date.now()
const raw = JSON.stringify(body) // sign the EXACT bytes you POST
const sig = crypto.createHmac('sha256', WEBHOOK_SECRET)
.update(`chat_push.${subscriptionId}.${ts}.${sha256hex(raw)}`).digest('hex')
// POST headers: X-AgentDukaan-Sig: sig, X-AgentDukaan-Ts: ts
6.4 Durable per-conversation state — GET/PUT
A small opaque JSON blob the platform stores per conversation and rides back into
every inbound as state (§6.2). Use it for wizard position, counters, or
last-seen markers — anything you'd otherwise keep in your own DB. It is not for
secrets (those stay on the pull-token /config path, §2).
GET /api/subscriptions/:id/chat/state → { "state": { … }, "updatedAt": "…" }
PUT /api/subscriptions/:id/chat/state body: { "state": { … } }
Each signs its own domain:
// GET — a short-lived read token
const sigGet = crypto.createHmac('sha256', WEBHOOK_SECRET)
.update(`${subscriptionId}.${ts}`).digest('hex')
// PUT — body-bound (raw = JSON.stringify({ state }))
const sigPut = crypto.createHmac('sha256', WEBHOOK_SECRET)
.update(`chat_state.${subscriptionId}.${ts}.${sha256hex(raw)}`).digest('hex')
6.5 ChatBlock kit — what changed
- The
chartcapability was removed — the validator now rejects it, so drop it from any manifestcapabilities.blocks. - A
progressblock was added (declare"progress"incapabilities.blocksto emit it). - A button may set
confirmto gate a destructive press behind a confirmation step. formfields gained arangefield type (a min/max slider).
6.6 Files the buyer attaches — don't trust declared size/mime
When a buyer attaches a file, the inbound fileRef carries sizeBytes and mime
that are buyer-declared, not verified by the platform. Do not trust them
for security decisions (allow/deny, parser selection, quota). Always re-check the
actual bytes after you fetch the file — verify the real content type and size
yourself before processing.