curl --request POST \
--url https://api.monaco.com/v1/webhooks/subscriptions \
--header 'Content-Type: application/json' \
--data '
{
"url": "https://hooks.example.com/monaco",
"triggers": [
{
"trigger_key": "deal.stage_changed",
"predicate": {
"to_value": "closed-won"
}
}
],
"custom_field_schema_ids": [
"3c90c3cc-0d44-4b50-8888-8dd25736052a"
]
}
'import requests
url = "https://api.monaco.com/v1/webhooks/subscriptions"
payload = {
"url": "https://hooks.example.com/monaco",
"triggers": [
{
"trigger_key": "deal.stage_changed",
"predicate": { "to_value": "closed-won" }
}
],
"custom_field_schema_ids": ["3c90c3cc-0d44-4b50-8888-8dd25736052a"]
}
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
url: 'https://hooks.example.com/monaco',
triggers: [{trigger_key: 'deal.stage_changed', predicate: {to_value: 'closed-won'}}],
custom_field_schema_ids: ['3c90c3cc-0d44-4b50-8888-8dd25736052a']
})
};
fetch('https://api.monaco.com/v1/webhooks/subscriptions', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.monaco.com/v1/webhooks/subscriptions",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://hooks.example.com/monaco',
'triggers' => [
[
'trigger_key' => 'deal.stage_changed',
'predicate' => [
'to_value' => 'closed-won'
]
]
],
'custom_field_schema_ids' => [
'3c90c3cc-0d44-4b50-8888-8dd25736052a'
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.monaco.com/v1/webhooks/subscriptions"
payload := strings.NewReader("{\n \"url\": \"https://hooks.example.com/monaco\",\n \"triggers\": [\n {\n \"trigger_key\": \"deal.stage_changed\",\n \"predicate\": {\n \"to_value\": \"closed-won\"\n }\n }\n ],\n \"custom_field_schema_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.monaco.com/v1/webhooks/subscriptions")
.header("Content-Type", "application/json")
.body("{\n \"url\": \"https://hooks.example.com/monaco\",\n \"triggers\": [\n {\n \"trigger_key\": \"deal.stage_changed\",\n \"predicate\": {\n \"to_value\": \"closed-won\"\n }\n }\n ],\n \"custom_field_schema_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.monaco.com/v1/webhooks/subscriptions")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"url\": \"https://hooks.example.com/monaco\",\n \"triggers\": [\n {\n \"trigger_key\": \"deal.stage_changed\",\n \"predicate\": {\n \"to_value\": \"closed-won\"\n }\n }\n ],\n \"custom_field_schema_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ]\n}"
response = http.request(request)
puts response.read_body{
"data": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"url": "<string>",
"enabled": true,
"created_at": "2023-11-07T05:31:56Z",
"secret": "whsec_...",
"custom_field_schema_ids": [
"3c90c3cc-0d44-4b50-8888-8dd25736052a"
],
"triggers": [
{
"trigger_key": "deal.stage_changed",
"activated_at": "2023-11-07T05:31:56Z",
"predicate": {}
}
]
},
"meta": {
"timestamp": "2023-11-07T05:31:56Z"
}
}{
"error": {
"code": "not_found",
"message": "Resource not found"
}
}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.
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. |
curl --request POST \
--url https://api.monaco.com/v1/webhooks/subscriptions \
--header 'Content-Type: application/json' \
--data '
{
"url": "https://hooks.example.com/monaco",
"triggers": [
{
"trigger_key": "deal.stage_changed",
"predicate": {
"to_value": "closed-won"
}
}
],
"custom_field_schema_ids": [
"3c90c3cc-0d44-4b50-8888-8dd25736052a"
]
}
'import requests
url = "https://api.monaco.com/v1/webhooks/subscriptions"
payload = {
"url": "https://hooks.example.com/monaco",
"triggers": [
{
"trigger_key": "deal.stage_changed",
"predicate": { "to_value": "closed-won" }
}
],
"custom_field_schema_ids": ["3c90c3cc-0d44-4b50-8888-8dd25736052a"]
}
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
url: 'https://hooks.example.com/monaco',
triggers: [{trigger_key: 'deal.stage_changed', predicate: {to_value: 'closed-won'}}],
custom_field_schema_ids: ['3c90c3cc-0d44-4b50-8888-8dd25736052a']
})
};
fetch('https://api.monaco.com/v1/webhooks/subscriptions', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.monaco.com/v1/webhooks/subscriptions",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://hooks.example.com/monaco',
'triggers' => [
[
'trigger_key' => 'deal.stage_changed',
'predicate' => [
'to_value' => 'closed-won'
]
]
],
'custom_field_schema_ids' => [
'3c90c3cc-0d44-4b50-8888-8dd25736052a'
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.monaco.com/v1/webhooks/subscriptions"
payload := strings.NewReader("{\n \"url\": \"https://hooks.example.com/monaco\",\n \"triggers\": [\n {\n \"trigger_key\": \"deal.stage_changed\",\n \"predicate\": {\n \"to_value\": \"closed-won\"\n }\n }\n ],\n \"custom_field_schema_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.monaco.com/v1/webhooks/subscriptions")
.header("Content-Type", "application/json")
.body("{\n \"url\": \"https://hooks.example.com/monaco\",\n \"triggers\": [\n {\n \"trigger_key\": \"deal.stage_changed\",\n \"predicate\": {\n \"to_value\": \"closed-won\"\n }\n }\n ],\n \"custom_field_schema_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.monaco.com/v1/webhooks/subscriptions")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"url\": \"https://hooks.example.com/monaco\",\n \"triggers\": [\n {\n \"trigger_key\": \"deal.stage_changed\",\n \"predicate\": {\n \"to_value\": \"closed-won\"\n }\n }\n ],\n \"custom_field_schema_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ]\n}"
response = http.request(request)
puts response.read_body{
"data": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"url": "<string>",
"enabled": true,
"created_at": "2023-11-07T05:31:56Z",
"secret": "whsec_...",
"custom_field_schema_ids": [
"3c90c3cc-0d44-4b50-8888-8dd25736052a"
],
"triggers": [
{
"trigger_key": "deal.stage_changed",
"activated_at": "2023-11-07T05:31:56Z",
"predicate": {}
}
]
},
"meta": {
"timestamp": "2023-11-07T05:31:56Z"
}
}{
"error": {
"code": "not_found",
"message": "Resource not found"
}
}Body
HTTPS endpoint that receives the POST
"https://hooks.example.com/monaco"
Event triggers to arm; OR semantics across the list
1Show child attributes
Show child attributes
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.