Skip to content

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 HMAC
  • X-AgentDukaan-Tsts (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_configured webhook 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 /heartbeat return 403 SUBSCRIPTION_NOT_ACTIVE once 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/actions are the block types and platform-actions your manifest declared intersected with what this renderer supports; capsVersion is the renderer's current ceiling; proactive says 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 chart capability was removed — the validator now rejects it, so drop it from any manifest capabilities.blocks.
  • A progress block was added (declare "progress" in capabilities.blocks to emit it).
  • A button may set confirm to gate a destructive press behind a confirmation step.
  • form fields gained a range field 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.