Webhooks
Subscribe to async JECP events. HMAC-SHA256 signed. Outbox-pattern delivery with exponential backoff.
Event types (v1)
| Event | Fires when | Subscriber |
|---|---|---|
invocation.completed | Successful charge | Agent + Provider |
invocation.refunded | Refund processed (90% credited) | Agent + Provider |
refund.requested | Agent files a refund | Provider |
refund.denied | Provider denies refund | Agent + Provider |
wallet.low_balance | Balance ≤ $0.50 after charge | Agent |
provider.kyc_status_changed | Stripe Connect KYC update | Provider |
test.synthetic | Manual test from POST /test endpoint | Caller |
1. Subscribe
const sub = await jecp.subscribe({
endpoint_url: 'https://myapp.com/jecp/webhook',
events: ['invocation.completed', 'wallet.low_balance'], // empty = subscribe to all
});
console.log(sub.subscription_id); // uuid
console.log(sub.hmac_secret); // SHOWN ONCE — save it now
The
hmac_secret is shown only on creation. If you lose it, delete the subscription and create a new one.
2. Verify inbound events
Each event POST includes X-JECP-Webhook-Signature and X-JECP-Webhook-Timestamp headers. Use the SDK's verifyWebhook() helper:
import { verifyWebhook, WebhookVerificationError } from '@jecpdev/sdk';
app.post('/jecp/webhook', async (req, res) => {
try {
const event = await verifyWebhook({
body: req.rawBody,
signature: req.headers['x-jecp-webhook-signature'],
timestamp: req.headers['x-jecp-webhook-timestamp'],
secret: process.env.JECP_WEBHOOK_SECRET!,
});
switch (event.type) {
case 'invocation.completed':
await trackRevenue(event.data);
break;
case 'wallet.low_balance':
await alertOps(event.data.balance_after);
break;
}
res.status(200).send('ok');
} catch (e) {
if (e instanceof WebhookVerificationError) {
res.status(401).send('invalid signature');
} else {
res.status(500).send('error');
}
}
});
Event payload structure
{
"type": "invocation.completed",
"id": "evt_abc123",
"created_at": "2026-05-09T15:00:00Z",
"data": {
"transaction_id": "tx-abc",
"agent_id": "jdb_ag_...",
"provider_id": "uuid",
"capability_id": "uuid",
"action": "translate",
"amount_usdc": 0.005,
"balance_after": 19.995,
"provider_share_usdc": 0.00425
}
}
Delivery guarantees
- At-least-once — handle idempotency via
event.id - Outbox pattern — events committed to DB in same TX as the underlying state change
- Retry schedule — exponential backoff: 1m, 2m, 4m, 8m, 16m, 32m, 1h × 6 → 12 attempts ~6h total
- Dead letter — after 12 failures, event is abandoned (manually queryable in
webhook_outbox) - HMAC-SHA256 — signature = base64(hmac_sha256(secret, ts + "." + body))
- Replay window — ±5 min default (configurable via
verifyWebhookoptions)
Test your endpoint
// Trigger a synthetic event to your endpoint
await jecp.testSubscription(sub.subscription_id);
// or via CLI:
// jecp webhook test <subscription_id>
Manage subscriptions
const list = await jecp.listSubscriptions();
console.log(list.subscriptions);
// PATCH (CLI: jecp webhook update)
await fetch('https://jecp.dev/v1/subscriptions/SUB_ID', {
method: 'PATCH',
headers: { 'X-Agent-ID': '...', 'X-API-Key': '...', 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'paused' }),
});
Common patterns
Agent-side: auto-topup on low balance
// On wallet.low_balance event:
if (event.type === 'wallet.low_balance' && event.data.balance_after < 1) {
const { url } = await jecp.topup(20);
// Notify user / open URL / etc.
}
Provider-side: track revenue in real time
if (event.type === 'invocation.completed') {
await db.revenue.insert({
transaction_id: event.data.transaction_id,
amount_usdc: event.data.provider_share_usdc,
capability: event.data.action,
});
}