curl --request POST \
--url https://api.citadel.example/api/v1/webhook_endpoints \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"url": "https://merchant.example/hooks/citadel",
"description": "Production backend",
"event_types": [
"operation.confirmed",
"operation.failed",
"blockchain_event.confirmed"
]
}
'const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
url: 'https://merchant.example/hooks/citadel',
description: 'Production backend',
event_types: ['operation.confirmed', 'operation.failed', 'blockchain_event.confirmed']
})
};
fetch('https://api.citadel.example/api/v1/webhook_endpoints', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://api.citadel.example/api/v1/webhook_endpoints"
payload = {
"url": "https://merchant.example/hooks/citadel",
"description": "Production backend",
"event_types": ["operation.confirmed", "operation.failed", "blockchain_event.confirmed"]
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.citadel.example/api/v1/webhook_endpoints"
payload := strings.NewReader("{\n \"url\": \"https://merchant.example/hooks/citadel\",\n \"description\": \"Production backend\",\n \"event_types\": [\n \"operation.confirmed\",\n \"operation.failed\",\n \"blockchain_event.confirmed\"\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
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))
}{
"id": "0199a2f0-1b00-7a11-8c22-3d4e5f6a7b8c",
"url": "https://merchant.example/hooks/citadel",
"description": "Production backend",
"event_types": [
"operation.confirmed",
"operation.failed",
"blockchain_event.confirmed"
],
"state": "active",
"disabled_at": null,
"consecutive_failures": 0,
"previous_secret_expires_at": null,
"created_at": "2026-10-01T09:00:00Z",
"updated_at": "2026-10-01T09:00:00Z",
"secret": "whsec_7Qh2…"
}Add a webhook endpoint
Registers a URL to receive webhooks. Citadel generates its signing secret and shows it in this answer only: store it now. Lost it? Rotate the secret.
One endpoint per URL: a URL your organization already has answers
409 endpoint_exists and adds nothing, so an add sent twice (a lost response, a retried
script) never registers the URL twice. Change the existing one with PATCH instead.
url is an https:// URL with a host; https:// alone, or a URL with a space in its
host, answers 422. A field the endpoint does not take (secret, network, …), or a field
sent in the query string instead of the body, is refused with 422, never ignored. Needs
the webhooks.write scope. A key limited to vaults is refused with 403 vault_limited.
curl --request POST \
--url https://api.citadel.example/api/v1/webhook_endpoints \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"url": "https://merchant.example/hooks/citadel",
"description": "Production backend",
"event_types": [
"operation.confirmed",
"operation.failed",
"blockchain_event.confirmed"
]
}
'const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
url: 'https://merchant.example/hooks/citadel',
description: 'Production backend',
event_types: ['operation.confirmed', 'operation.failed', 'blockchain_event.confirmed']
})
};
fetch('https://api.citadel.example/api/v1/webhook_endpoints', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://api.citadel.example/api/v1/webhook_endpoints"
payload = {
"url": "https://merchant.example/hooks/citadel",
"description": "Production backend",
"event_types": ["operation.confirmed", "operation.failed", "blockchain_event.confirmed"]
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.citadel.example/api/v1/webhook_endpoints"
payload := strings.NewReader("{\n \"url\": \"https://merchant.example/hooks/citadel\",\n \"description\": \"Production backend\",\n \"event_types\": [\n \"operation.confirmed\",\n \"operation.failed\",\n \"blockchain_event.confirmed\"\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
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))
}{
"id": "0199a2f0-1b00-7a11-8c22-3d4e5f6a7b8c",
"url": "https://merchant.example/hooks/citadel",
"description": "Production backend",
"event_types": [
"operation.confirmed",
"operation.failed",
"blockchain_event.confirmed"
],
"state": "active",
"disabled_at": null,
"consecutive_failures": 0,
"previous_secret_expires_at": null,
"created_at": "2026-10-01T09:00:00Z",
"updated_at": "2026-10-01T09:00:00Z",
"secret": "whsec_7Qh2…"
}Authorizations
An API key minted in the console (ctd_…). Authenticates; never authorizes funds. A key
carries scopes (read, wallets.write, transactions.write, events.write,
webhooks.write, policy.write) and may be limited to vaults, to IP addresses, and in
lifetime — see Authentication.
Body
An https:// URL with a host. One endpoint per URL in your organization.
The events it hears; absent or [], every event.
operation.confirmed, operation.failed, operation.expired, operation.stuck, operation.awaiting_approval, operation.approved, blockchain_event.confirmed, run.completed, run.failed, wallet.active, wallet.failed Response
Added. The answer carries the signing secret, once.
The endpoint and its signing secret, shown in this answer only.
Where Citadel posts. An https:// URL with a host.
What the endpoint is for — your words.
The events it hears. [] hears every event.
operation.confirmed, operation.failed, operation.expired, operation.stuck, operation.awaiting_approval, operation.approved, blockchain_event.confirmed, run.completed, run.failed, wallet.active, wallet.failed active receives its events. paused: you paused it. disabled: Citadel turned it off
after 5 deliveries in a row exhausted their attempts. Neither of the last two gets a
delivery, not even a later one: PATCH with state: active resumes it.
active, paused, disabled When it was paused or disabled; null while active.
Deliveries in a row that exhausted their attempts. At 5 the endpoint is disabled; a delivered one, or a resume, sets it back to 0.
While a rotated secret still signs next to the new one, until when; else null.
The signing secret (whsec_…). Citadel shows it here only — store it now.