Create a Webhook Subscription
Changed onRegisters an endpoint that receives a signed POST when one of the armed triggers fires.
Event keys. Entity events carry the changed record under data, with a changes diff on updates: deal.created, deal.stage_changed, deal.won, deal.field_changed, account.created, account.field_changed, contact.created, contact.field_changed, meeting.booked, meeting.transcript_ready, task.created, task.updated, linkedin.connection_made.
contact.created fires when a user or the API creates a contact and carries its account_id. It takes no predicate, and is not sent for contacts created by imports, discovery or enrichment.
message.received fires on an inbound email and message.sent on an outbound one. Both carry the message under data.message, matching the shape GET /v1/messages returns, with contact_id and account_id set from the sender of a received message or the first known recipient of a sent one. They send a bounded preview, never the full body, take no predicate, and are not sent for internal mail or for the history backfilled when a mailbox is first connected.
sequence.replied fires when a contact replies to a sequence, after Monaco classifies the reply. It carries data.reply with sequence_id, campaign_id, contact_id, account_id, user_id (the sequence owner) and sentiment (positive, neutral, negative, or null when the reply was not classified). It sends no message body; fetch the reply with POST /v1/messages/list filtered to its contact_id. It takes no predicate.
Destination rules. url must use https (outside production http is also accepted) on port 443 (outside production 80 is also accepted), be at most 2048 characters, contain no whitespace or control characters, embed no credentials, and neither name nor resolve to a private, loopback or link-local address. The destination is re-resolved and re-checked immediately before every delivery, and the connection is pinned to the address that was checked.
Verifying a delivery. The secret returned by create and rotate is shown once and never again. Every delivery carries these headers:
| Header | Value |
|---|---|
| X-Monaco-Signature | t=<unix seconds>,n=<nonce>,v1=<hex hmac> |
| X-Monaco-Timestamp | the same <unix seconds> |
| X-Monaco-Nonce | the same <nonce>: 32 hex characters, unique per delivery |
| X-Monaco-Org | the org the delivery belongs to |
| X-Monaco-Event | the trigger key that fired |
| X-Monaco-Delivery | the source event id, equal across retries |
v1 is HMAC-SHA256(secret, "<t>.<nonce>.<org id>.<raw body>") — four dot-joined parts, over the exact bytes received, computed before any JSON parsing or re-serialization.
import hashlib, hmac
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in headers["X-Monaco-Signature"].split(","))
message = b".".join([
parts["t"].encode(), parts["n"].encode(),
headers["X-Monaco-Org"].encode(), raw_body,
])
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Worked example. Secret whsec_example_do_not_use, X-Monaco-Signature: t=1758592800,n=9f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f,v1=<hex>, X-Monaco-Org: 0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b, and the raw body
{"id":"01994f2c-7a10-7b3e-9d21-6f0a5c8e1234","event":"deal.stage_changed","version":7,"occurred_at":"2026-09-23T04:40:00Z","data":{}}
The signed message is 1758592800.9f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f.0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b. followed by those body bytes, and
v1=332e46bb14f0ff163229727cc402bfafd65f7da946070374fcc53f711eebfc71
Reject a delivery whose timestamp is outside your tolerance (five minutes is typical), and one whose nonce you have already seen, before comparing digests. Compare digests in constant time.
Delivery semantics. Delivery is at-least-once: the same envelope can arrive more than once, so deduplicate on the envelope's id, which is stable across retries and equal to X-Monaco-Delivery. Order is not guaranteed. The envelope's version increases with every change to the entity, so ignore an envelope whose version is not greater than the last one you applied to that entity. Any 2xx acknowledges the delivery; anything else is retried about 10s, 20s and 40s later and then dead-lettered, except a 4xx other than 408 and 429, which is terminal and never retried. Redirects are not followed, and the request times out after 10s.
Error codes
| error.code | HTTP | Cause | What to do |
|---|---|---|---|
| invalid_url_scheme | 422 | url is not https (outside production http is also accepted). | Send an https URL. |
| invalid_url_host | 422 | url has no host. | Send an absolute URL including the host. |
| invalid_url_private | 422 | url names, or resolves to, a loopback, link-local or private address. | Point the subscription at a publicly routable endpoint. |
| invalid_url_format | 422 | url contains whitespace, a control character, or is otherwise unparseable. | Percent-encode the URL and strip any newline or tab before sending it. |
| invalid_url_port | 422 | url names a port other than 443 (outside production 80 is also accepted). | Serve the endpoint on 443. |
| invalid_url_userinfo | 422 | url embeds credentials, as in https://user:pass@host/hook. | Remove the userinfo and authenticate the delivery with the signature instead. Treat any credential already sent this way as leaked and rotate it. |
| invalid_url_length | 422 | url is longer than 2048 characters. | Shorten the URL; move state into the path or a query parameter you can look up. |
| no_triggers | 422 | triggers is empty. | Arm at least one trigger. |
| unknown_trigger_key | 422 | A trigger_key is not a published event key. | Use one of the documented trigger keys. |
| invalid_predicate | 422 | A trigger's predicate is malformed, missing a required key, or names a field outside the allowlist. | Correct the predicate; the message names the offending key. |
| duplicate_trigger | 422 | Two triggers in the request have the same key and an identical predicate. | Remove the duplicate. |
| trigger_limit_reached | 422 | The request arms more triggers than one subscription may hold. | Split the triggers across several subscriptions. |
| custom_field_limit_reached | 422 | The request grants more custom field schemas than one subscription may hold. | Grant only the schemas the receiver reads. |
| unknown_custom_field_schema | 422 | A custom_field_schema_ids entry does not exist in the calling org. | Look the schema ids up with GET /v1/schemas/{entity}. |
| subscription_limit_reached | 409 | The org is already at its active subscription cap. | Delete a subscription before creating another. |
| duplicate_subscription_url | 409 | An active subscription for this org already uses this url. | Reuse that subscription, or delete it first. |
Request
- Base URLhttps://api.monaco.com
- URLhttps://api.monaco.com/v1/webhooks/subscriptions
- Authnone declared
Request body
Example request
{
"url": "https://hooks.example.com/monaco",
"triggers": [
{
"trigger_key": "deal.stage_changed",
"predicate": {
"to_value": "closed-won"
}
}
]
}Response
Successful Response
Example response
{
"data": {
"triggers": [
{
"trigger_key": "deal.stage_changed"
}
],
"secret": "whsec_..."
}
}Changes
- info
endpoint added
- info