Webhooks

Create a Webhook Subscription

Changed on

Registers 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:

HeaderValue
X-Monaco-Signaturet=<unix seconds>,n=<nonce>,v1=<hex hmac>
X-Monaco-Timestampthe same <unix seconds>
X-Monaco-Noncethe same <nonce>: 32 hex characters, unique per delivery
X-Monaco-Orgthe org the delivery belongs to
X-Monaco-Eventthe trigger key that fired
X-Monaco-Deliverythe 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.codeHTTPCauseWhat to do
invalid_url_scheme422url is not https (outside production http is also accepted).Send an https URL.
invalid_url_host422url has no host.Send an absolute URL including the host.
invalid_url_private422url names, or resolves to, a loopback, link-local or private address.Point the subscription at a publicly routable endpoint.
invalid_url_format422url contains whitespace, a control character, or is otherwise unparseable.Percent-encode the URL and strip any newline or tab before sending it.
invalid_url_port422url names a port other than 443 (outside production 80 is also accepted).Serve the endpoint on 443.
invalid_url_userinfo422url 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_length422url is longer than 2048 characters.Shorten the URL; move state into the path or a query parameter you can look up.
no_triggers422triggers is empty.Arm at least one trigger.
unknown_trigger_key422A trigger_key is not a published event key.Use one of the documented trigger keys.
invalid_predicate422A 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_trigger422Two triggers in the request have the same key and an identical predicate.Remove the duplicate.
trigger_limit_reached422The request arms more triggers than one subscription may hold.Split the triggers across several subscriptions.
custom_field_limit_reached422The request grants more custom field schemas than one subscription may hold.Grant only the schemas the receiver reads.
unknown_custom_field_schema422A custom_field_schema_ids entry does not exist in the calling org.Look the schema ids up with GET /v1/schemas/{entity}.
subscription_limit_reached409The org is already at its active subscription cap.Delete a subscription before creating another.
duplicate_subscription_url409An active subscription for this org already uses this url.Reuse that subscription, or delete it first.
post/v1/webhooks/subscriptions

Request

  • Base URL
    https://api.monaco.com
  • URL
    https://api.monaco.com/v1/webhooks/subscriptions
  • Auth
    none declared

Request body

urlstring required

HTTPS endpoint that receives the POST

custom_field_schema_idsstring[] nullable

Custom field schema ids to include in the delivered payload. Custom fields are outside the standard payload allowlist, so this list is the grant: nothing is sent for a schema id it does not name.

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