---
title: "Create a Webhook Subscription"
method: POST
path: "/v1/webhooks/subscriptions"
tags: ["Webhooks"]
---

# Create a Webhook Subscription

`POST /v1/webhooks/subscriptions`

## Request

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

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:

| 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.

```python
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

```json
{"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 body

- WebhookSubscriptionCreateRequest
  - `url` string, required: HTTPS endpoint that receives the POST
  - `triggers` WebhookTriggerRequest[], required: Event triggers to arm; OR semantics across the list
    - `trigger_key` string, required: Public event key to subscribe to
    - `predicate` object, nullable: Optional filter narrowing the trigger. `deal.stage_changed` accepts `{"to_value": "<stage key>"}`; `*.field_changed` requires `{"field": "<field or custom_field_<uuid>>"}`. `message.received`, `message.sent`, `sequence.replied` and `contact.created` take no predicate.
  - `custom_field_schema_ids` string[], 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.

## Response `201`

Successful Response

- PublicItemResponseWebhookSubscriptionCreateResponse
  - `data` WebhookSubscriptionCreateResponse, required
    - `id` string, uuid, required
    - `url` string, required
    - `enabled` boolean, required
    - `custom_field_schema_ids` string[], nullable
    - `triggers` WebhookTriggerResponse[]
      - `trigger_key` string, required
      - `predicate` object, nullable
      - `activated_at` string, date-time, required: Events that occurred before this timestamp never fire this trigger
    - `created_at` string, date-time, required
    - `secret` string, required: Signing secret for `X-Monaco-Signature`. Returned only here — it is stored encrypted and cannot be read back.
  - `meta` ResponseMeta
    - `timestamp` string, date-time: Server timestamp of the response

## Other responses

- `4XX`: Client error

## Changes

- **2026-11-03** `fc720ada2a81`: 1 info
  - endpoint added

[Operation history](https://skmtc.dev/monaco/apis/monaco-public-api/changes/v1/webhooks/subscriptions/post.md)

---

[API](https://skmtc.dev/monaco/apis/monaco-public-api.md) - [All operations](https://skmtc.dev/monaco/apis/monaco-public-api/llms.txt) - [OpenAPI document](https://skmtc.dev/monaco/apis/monaco-public-api/revisions/fc720ada2a81?raw)
