> ## Documentation Index
> Fetch the complete documentation index at: https://docs.monaco.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Create a Webhook Subscription

> Registers an endpoint that receives a signed POST when one of the armed triggers fires.

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



## OpenAPI

````yaml https://api.monaco.com/openapi.json post /v1/webhooks/subscriptions
openapi: 3.1.0
info:
  title: Monaco Public API
  description: Public API for Monaco
  version: 1.0.0
servers:
  - url: https://api.monaco.com
security: []
paths:
  /v1/webhooks/subscriptions:
    post:
      tags:
        - Webhooks
      summary: Create a Webhook Subscription
      description: >-
        Registers an endpoint that receives a signed POST when one of the armed
        triggers fires.


        **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. |
      operationId: create_webhook_subscription
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookSubscriptionCreateRequest'
        required: true
      responses:
        '201':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: >-
                  #/components/schemas/PublicItemResponse_WebhookSubscriptionCreateResponse_
        4XX:
          description: Client error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicErrorResponse'
components:
  schemas:
    WebhookSubscriptionCreateRequest:
      properties:
        url:
          type: string
          title: Url
          description: HTTPS endpoint that receives the POST
          examples:
            - https://hooks.example.com/monaco
        triggers:
          items:
            $ref: '#/components/schemas/WebhookTriggerRequest'
          type: array
          minItems: 1
          title: Triggers
          description: Event triggers to arm; OR semantics across the list
        custom_field_schema_ids:
          anyOf:
            - items:
                type: string
                format: uuid
              type: array
            - type: 'null'
          title: Custom Field Schema Ids
          description: >-
            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.
      type: object
      required:
        - url
        - triggers
      title: WebhookSubscriptionCreateRequest
    PublicItemResponse_WebhookSubscriptionCreateResponse_:
      properties:
        data:
          $ref: '#/components/schemas/WebhookSubscriptionCreateResponse'
        meta:
          $ref: '#/components/schemas/ResponseMeta'
      type: object
      required:
        - data
      title: PublicItemResponse[WebhookSubscriptionCreateResponse]
    PublicErrorResponse:
      properties:
        error:
          $ref: '#/components/schemas/ErrorDetail'
      type: object
      required:
        - error
      title: PublicErrorResponse
    WebhookTriggerRequest:
      properties:
        trigger_key:
          type: string
          title: Trigger Key
          description: Public event key to subscribe to
          examples:
            - deal.stage_changed
        predicate:
          anyOf:
            - additionalProperties: true
              type: object
            - type: 'null'
          title: Predicate
          description: >-
            Optional filter narrowing the trigger. `deal.stage_changed` accepts
            `{"to_value": "<stage key>"}`; `*.field_changed` requires `{"field":
            "<field or custom_field_<uuid>>"}`.
          examples:
            - to_value: closed-won
      type: object
      required:
        - trigger_key
      title: WebhookTriggerRequest
    WebhookSubscriptionCreateResponse:
      properties:
        id:
          type: string
          format: uuid
          title: Id
        url:
          type: string
          title: Url
        enabled:
          type: boolean
          title: Enabled
        custom_field_schema_ids:
          anyOf:
            - items:
                type: string
                format: uuid
              type: array
            - type: 'null'
          title: Custom Field Schema Ids
        triggers:
          items:
            $ref: '#/components/schemas/WebhookTriggerResponse'
          type: array
          title: Triggers
        created_at:
          type: string
          format: date-time
          title: Created At
        secret:
          type: string
          title: Secret
          description: >-
            Signing secret for `X-Monaco-Signature`. Returned only here — it is
            stored encrypted and cannot be read back.
          examples:
            - whsec_...
      type: object
      required:
        - id
        - url
        - enabled
        - created_at
        - secret
      title: WebhookSubscriptionCreateResponse
    ResponseMeta:
      properties:
        timestamp:
          type: string
          format: date-time
          title: Timestamp
          description: Server timestamp of the response
      type: object
      title: ResponseMeta
    ErrorDetail:
      properties:
        code:
          type: string
          title: Code
          description: Machine-readable error code
          examples:
            - not_found
        message:
          type: string
          title: Message
          description: Human-readable error message
          examples:
            - Resource not found
      type: object
      required:
        - code
        - message
      title: ErrorDetail
    WebhookTriggerResponse:
      properties:
        trigger_key:
          type: string
          title: Trigger Key
          examples:
            - deal.stage_changed
        predicate:
          anyOf:
            - additionalProperties: true
              type: object
            - type: 'null'
          title: Predicate
        activated_at:
          type: string
          format: date-time
          title: Activated At
          description: Events that occurred before this timestamp never fire this trigger
      type: object
      required:
        - trigger_key
        - activated_at
      title: WebhookTriggerResponse

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.