Webhooks
A webhook is a URL on your server that Chain Daddy sends a POST request to when something happens: a token is registered, an alert fires, a deal is accepted, an item sells in your store. You get the event within seconds instead of asking for it again and again.
Plan
Webhooks need the Developer plan (5 webhooks), which is in beta and free while it is, or a Partner plan (25). A store's own webhook does not: it comes with the store, on any paid plan. See the API feature matrix.
Managing webhooks needs only your API key. Every route and field is in the API Explorer under Webhooks; you can also manage them at chaindaddy.io/_api.
Set one up
1. Create it
curl -X POST https://api.chaindaddy.io/api/v2/webhooks \
-H "X-API-Key: $CHAINDADDY_API_KEY" -H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/chaindaddy",
"events": ["token.minted", "heartbeat.warning"]
}'{
"success": true,
"webhook": {
"id": "b2a1…",
"url": "https://example.com/webhooks/chaindaddy",
"secret": "whsec_4f1c…",
"events": ["token.minted", "heartbeat.warning"],
"active": true,
"failure_count": 0
}
}Save secret now. It is shown only in this response, and you need it to check signatures. url must be https:// on a public address. An event type that does not exist is a 400; creating more webhooks than your plan allows is a 403 with webhook_limit_reached.
2. Check the signature
Every delivery is signed the Standard Webhooks way, so any Standard Webhooks library checks it with the secret exactly as you saved it, whsec_ included:
| Header | Value |
|---|---|
webhook-id | The event's id, the same on every retry of it |
webhook-timestamp | When this attempt was sent, in unix seconds |
webhook-signature | v1, and the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body} |
The HMAC key is the base64-decode of what follows whsec_. Sign the raw bytes you received, before any JSON parsing: a re-serialised body will not match. Because the timestamp is signed, you can refuse a delivery older than a few minutes, and a captured one cannot be replayed later.
const { Webhook } = require('standardwebhooks'); // npm i standardwebhooks
const wh = new Webhook(process.env.WEBHOOK_SECRET); // the secret as you saved it, whsec_ included
app.post('/webhooks/chaindaddy', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers); // checks the signature and refuses a stale timestamp
} catch {
return res.status(401).end();
}
res.status(200).end(); // answer first
console.log(event.type, event.data); // then do the work
});import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook # pip install standardwebhooks
app = Flask(__name__)
wh = Webhook(os.environ['WEBHOOK_SECRET'])
@app.route('/webhooks/chaindaddy', methods=['POST'])
def handle():
try:
event = wh.verify(request.get_data(), request.headers)
except Exception:
return '', 401
print(event['type'], event['data'])
return '', 200func verify(body []byte, h http.Header, secret string) bool {
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
ts, err := strconv.ParseInt(h.Get("webhook-timestamp"), 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false // missing, or more than 5 minutes off
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(h.Get("webhook-id") + "." + h.Get("webhook-timestamp") + "."))
mac.Write(body)
want := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, sig := range strings.Fields(h.Get("webhook-signature")) {
if hmac.Equal([]byte(sig), []byte(want)) {
return true
}
}
return false
}Retries make the same event arrive more than once; skip a webhook-id you have already handled. A whole receiver that does all of this with no framework (Node's own http and the same library) is in Take payments on your site: A complete handler.
Don't want to trust the webhook alone? A store payment can be verified on chain, with no trust in us at all.
The older X-Webhook-Signature header is deprecated and is sent until 2027-10-31. It is the hex HMAC-SHA256 of the body alone, keyed with the whole secret string (whsec_ included). It signs no timestamp, so it cannot tell a fresh delivery from a replayed one. If you check it today, move to webhook-signature before that date.
3. Answer with a 2xx within 5 seconds
Any other status, or no answer in 5 seconds, counts as a failed delivery. Answer first and do slow work afterwards.
4. Send yourself a test
curl -X POST https://api.chaindaddy.io/api/v2/webhooks/WEBHOOK_ID/test \
-H "X-API-Key: $CHAINDADDY_API_KEY"
# {"success":true,"message":"…","event":{"id":"evt_test.ping_…","type":"test.ping","created_at":"…","data":{…}}}This queues a real, signed delivery of a test.ping event to your URL. To try a real event's shape, send a body: {"eventType": "heartbeat.warning", "data": {…}}. Then read what happened:
curl -H "X-API-Key: $CHAINDADDY_API_KEY" \
"https://api.chaindaddy.io/api/v2/webhooks/WEBHOOK_ID/deliveries?limit=20"
# {"deliveries":[{"id":"…","event_type":"test.ping","status":"delivered","status_code":200,"attempt":1,…}]}Your server has to be reachable from the internet. To test against your own computer, open a tunnel (ngrok http 3000, or npx localtunnel --port 3000) and use its https:// URL as the webhook's url.
What a delivery looks like
{
"id": "evt_token.minted_1708617600000000000",
"type": "token.minted",
"created_at": "2026-02-22T12:00:00Z",
"data": {
"crown_id": "42",
"symbol": "DOGE",
"chain": "eip155:42161",
"owner": "0x1234567890abcdef1234567890abcdef12345678",
"tx_hash": "0xabcdef…"
}
}| Header | Value |
|---|---|
webhook-id | The event's id, the same on every retry of it |
webhook-timestamp | When this attempt was sent (unix seconds) |
webhook-signature | v1, + base64 HMAC-SHA256 of id.timestamp.body (check it) |
X-Webhook-Event | The event type, e.g. token.minted |
X-Webhook-Delivery | The event's id (same as webhook-id) |
X-Webhook-Timestamp | When the event was created (ISO 8601); unsigned |
X-Webhook-Signature | Deprecated, sent until 2027-10-31: hex HMAC-SHA256 of the body alone |
User-Agent | ChainDaddy-Webhook/1.0 |
crown_id is the token's registration id, and chain is a CAIP-2 id (eip155:8453 is Base).
When a delivery fails
A delivery fails when your endpoint answers anything but a 2xx, takes more than 5 seconds, or cannot be reached.
Every event is written down before it is sent, so nothing is lost while your endpoint is down or while we deploy.
- A failed event is tried again 1 minute later, then 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours and 24 hours after that: 9 attempts over about 3 days. After the last one its status is
exhausted, and you can still send it again. - A webhook that has failed for 72 hours is switched off. The clock starts at its first failure and a success stops it, so an endpoint that is down for a day loses nothing: its retries arrive when it is back. At the first failure more than 72 hours in, nothing more is sent and the account that owns the webhook is notified. A test delivery is tried once and never counts toward this.
- Switch it back on once your endpoint is fixed. Everything that was waiting is sent at once:
curl -X POST https://api.chaindaddy.io/api/v2/webhooks/WEBHOOK_ID/reactivate \
-H "X-API-Key: $CHAINDADDY_API_KEY"Because of retries, the same event can arrive more than once. Keep the ids you have handled and skip repeats.
Send a delivery again
Any past delivery, delivered or given up on, can be sent again. It goes out with a fresh set of attempts (up to 9 over about 3 days: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, then a day and another day) and the same event id, so webhook-id repeats and your de-duplication skips it if you already handled it:
curl -X POST https://api.chaindaddy.io/api/v2/webhooks/WEBHOOK_ID/deliveries/DELIVERY_ID/redeliver \
-H "X-API-Key: $CHAINDADDY_API_KEY"
# {"queued":true,"eventId":"evt_token.minted_…"}DELIVERY_ID is a delivery's id from GET /api/v2/webhooks/WEBHOOK_ID/deliveries. Ten redeliveries a minute.
A creator store catches up from its event feed instead: POST /api/v2/iap/stores/{id}/webhook/redeliver {"after": 0, "limit": 1000} sends the store's events after after to the store's webhook again (only the event types the webhook is subscribed to), under their original ids, including any missed while the webhook was off. It answers {queued, last}; pass last as after to continue. A store key needs events:read; the CLI is chaindaddy iap webhook redeliver <store> --after <id>.
Receive only some events
Add a filter when you create or update a webhook:
{ "filter": { "crown_ids": [42, 108], "chains": ["eip155:42161", "eip155:8453"] } }crown_ids keeps events about those registrations, chains keeps events on those chains, and both together keep events that match both. Leave a list out for no filtering on it. Events with no token, such as subscription.upgraded, are always delivered.
Events about your account reach only your own webhooks: your alerts, your API usage, your plan, your coin's community, your deals and your store. Events about a token's public record (registrations, heartbeat, holders, trades, milestones, profile updates) reach every webhook subscribed to them, narrowed by its filter.
When you update a webhook, events replaces the whole list. Two old types, concentration.alert and ci.autofix.failed, are dropped when you save, so a list you read back can be saved as it is. Holder concentration now arrives as alert.triggered from a top10_concentration alert rule.
Events
GET /api/v2/webhooks/events returns this list with descriptions. Every event below also has id, type and created_at.
Token events
| Event | When | data |
|---|---|---|
token.minted | A token was registered | crown_id, symbol, chain, owner, tx_hash |
token.released | Its owner gave the registration up | crown_id, symbol, chain, previous_owner |
token.expired | A registration lapsed from inactivity and can be claimed again | crown_id, symbol, chain, previous_owner |
token.copycat_detected | A token imitating one of yours was found | sightings (each with chain_id, chain_name, address, explorer_url), action_url |
profile.updated | A coin page's profile changed | crown_id, symbol, chain, updated_fields |
heartbeat.warning | The health score fell below the warning line | crown_id, symbol, chain, score, threshold |
heartbeat.critical | The health score is critical | crown_id, symbol, chain, score, days_to_inactive |
heartbeat.recovered | The health score is healthy again | crown_id, symbol, chain, score, previous_score |
heartbeat.change | The health status changed, e.g. healthy to warning | crown_id, symbol, chain, previous_status, new_status, current_score, timestamp |
holder.new | A significant new holder appeared | crown_id, symbol, chain, holder_address, timestamp |
trade.large | A trade over 5% of daily volume, or over $10,000 | crown_id, symbol, chain, trade_volume_usd, direction (buy or sell), timestamp |
milestone.reached | Holders passed a milestone: 10, 25, 50, 100, 250, 500, 1K, 2.5K, 5K, 10K, 25K, 50K, 100K, 250K, 500K or 1M | crown_id, symbol, chain, milestone_type, milestone_value, previous_value, timestamp |
Account events
| Event | When | data |
|---|---|---|
alert.triggered | One of your alert rules fired | alert_id, alert_type, severity, title, body, wallet, symbol, chain, token_address, metric_value, threshold_value, timestamp |
alert.acknowledged | An alert was marked as seen | instance_id, wallet_address, alert_type, crown_symbol, timestamp |
alert.resolved | An alert was closed, by hand or by recovering | instance_id, wallet_address, alert_type, crown_symbol, reason, timestamp |
subscription.upgraded, subscription.downgraded | Your plan changed | wallet, previous_tier, new_tier |
usage.threshold | API usage passed 80% or 90% of your daily cap | threshold, used, limit, date |
quota.threshold_warning | API usage passed 80% of your daily cap. At most once a day | threshold, used, limit, date |
quota.exceeded | API usage passed 90% of your daily cap. At most once a day | threshold, used, limit, date |
community.member_joined | A wallet joined your coin's community | crown_id, wallet, source, timestamp |
community.member_left | A wallet left it, or was removed | crown_id, wallet, source, timestamp |
An event that is also sent to you as a notification (an alert on your own token, a deal, a copycat) carries title, body, action_url, action_label and symbol in data too.
Deal Events
| Event | When |
|---|---|
deal.created | An offer arrived on a registration you hold |
deal.accepted | The holder accepted an offer you made |
deal.rejected | The holder rejected an offer you made |
deal.cancelled | A deal you are a party to was cancelled before it settled |
deal.completed | A deal settled: the registration has moved to its new holder |
deal.failed | A deal was closed out without settling |
data carries deal_id, title, body, symbol, chain_id and action_url, and never the deal's terms. Read the deal with GET /api/v2/deals/{dealId} (Deals API).
In-app Store Events
Delivered to a store's own webhook (set in the store's settings) and to the webhooks of the wallet that opened the store. A subscription that is both gets each event once.
| Event | When |
|---|---|
iap.order.created | A buyer or your server created an order |
iap.order.paid | An order's payment is on chain, not yet confirmed |
iap.order.confirmed | An order reached its confirmations: items granted, receipt issued |
iap.order.expired | An order's payment window passed unpaid |
iap.order.reverted | A paid order's payment left the chain (a reorg); the order is pending again |
iap.order.refunded | The store's owner recorded paying the buyer back; the items and receipt were revoked |
iap.order.mismatch | A payment named an order's id but not its quote. The order is unchanged and can still be paid; nothing was granted |
iap.order.duplicate_payment | An order was paid a second time. Nothing more was granted; the second payment reached the seller, who settles it with the buyer |
iap.order.checkout_paid_again | A second order confirmed for a checkout session that was already paid. Both orders delivered; data.firstOrderId names the first, and only the seller can send the second payment back |
iap.order.fulfilment_updated | Physical items are sold only by Chain Daddy's own store for now. The store's own server reported a physical order's fulfilment: data.status is in_production, shipped, delivered, failed or canceled, with carrier, trackingNumber, trackingUrl and message when given |
iap.entitlement.granted | Items were added to a wallet |
iap.entitlement.consumed | Items were spent |
iap.entitlement.revoked | Items were taken back |
iap.entitlement.expiring | A wallet's timed item ends within 72 hours (only for an item held longer than that) |
iap.entitlement.expired | A wallet's timed item has ended |
iap.drop.claimed | A wallet claimed a drop |
iap.drop.awarded | Your server awarded a wallet from a drop |
iap.drop.exhausted | A drop reached its cap: everything in it has been given out |
iap.airdrop.award.submitted | A token prize your server awarded is on chain, waiting for confirmation |
iap.airdrop.award.confirmed | A token prize was paid: the winner has the tokens |
iap.airdrop.award.failed | A token prize was not paid (error says why); its amount is back in the pool's budget |
iap.sku.sold_out | A confirmed sale took an item to its maxSupply. Fires again only if supply is freed and it sells out again |
iap.store.not_live | The store stopped selling because the wallet that opened it lost the plan (reason: "plan_lapsed") |
iap.store.live | The store is selling again |
A subscription receives the event types it listed when it was saved; to receive a new type, save it again with that type included. data carries the store event as the store's event feed returns it; payloads are in the store guide and, for token prizes, Token Prizes. To be sure nothing is missed, read the feed from your last cursor with GET /api/v2/iap/stores/{storeId}/events?after= when a webhook arrives.
Paid App Events
Delivered to the webhooks of the wallet that owns a paid app, and to nobody else.
| Event | When |
|---|---|
app.order.confirmed | An order made through your app was confirmed: an item, an install licence or a payment. data carries the page, the amount, the fee and your share: the payload |