Webhooks.
A webhook is an address of yours that the app on a device may send a message to with Arda.webhook.call(slug, data). ArdaForm makes the call, signs it and keeps a log; the device never holds your key and never talks to your server.
About webhooks
- Addressed by
slug, the word the app uses —Arda.webhook.call('orders', {…}). The slug never changes once made;verifycannot be one. - The key is write-only. Set it with
"key": "…"; no answer ever contains it — onlyhasKey. When set, every delivery carries it asAuthorization: Bearerand is signed with it. - A webhook with no url only records. The app's call is answered
okwith status 202, nothing is sent anywhere, and the call waits in the delivery log for your systems to read: a mailbox. - A url must be
httporhttpson a public host: localhost, loopback, private and link-local addresses are refused when saved, and refused again when the call is made, after the name is resolved. - What your server receives, and how to check it came from ArdaForm: Receiving webhooks.
- The package decides:
webhooks(on or off),webhook_endpoints(how many, across the account) andwebhook_per_min(deliveries a minute). Reading always works.
GET/v1/webhooksEvery webhook on the key's project
Every webhook of the key's project, by slug.
Key scope webhooks:r — read webhooks
Answer 200 The list
| Field | Type | |
|---|---|---|
webhooks | array of Webhook |
Errors
| Status | Word | When |
|---|---|---|
| 403 | OUT_OF_SCOPE | The key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal. |
| 404 | NO_SPACE | No space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for. |
And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.
curl "https://api.ardaform.net/v1/webhooks" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/webhooks', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"webhooks": [
{
"slug": "zz-docs-mailbox",
"name": "Docs mailbox",
"url": null,
"method": "post",
"trigger": "app",
"dataset": null,
"debounceSeconds": 2,
"hasKey": false,
"verify": false,
"enabled": true,
"note": "Check-ins, read by the nightly job",
"createdAt": "2026-10-27T20:10:10.248Z",
"updatedAt": "2026-10-27T20:10:10.248Z"
},
{
"slug": "zz-docs-receiver",
"name": "Docs receiver",
"url": "https://ardaform.net/health",
"method": "post",
"trigger": "app",
"dataset": null,
"debounceSeconds": 2,
"hasKey": true,
"verify": true,
"enabled": true,
"note": null,
"createdAt": "2026-10-27T20:10:10.345Z",
"updatedAt": "2026-10-27T20:10:10.345Z"
}
]
}
POST/v1/webhooksCreate a webhook
Makes a webhook. name and slug are required. With verify: true every delivery also carries a one-time token your server can hand back to prove the call came from ArdaForm.
The receiver here points at ArdaForm's own website, which answers any request with ok — a public address for the examples.
Key scope webhooks:a — add webhooks
Body
| Field | Type | |
|---|---|---|
name required | string at least 1 character · at most 120 characters | For people. Trimmed; at most 120 characters. |
url | string, nullable at most 1024 characters | http or https, at most 1024 characters, a PUBLIC host: localhost and loopback, private and link-local addresses are 400 BAD_URL (and refused again by the plane at call time, after the name resolves). Absent, null or "" makes a webhook that only RECORDS its calls (url: null in the answer). |
method | string — post or get or put | post (the default) and put send the envelope as a JSON body; get sends it as query parameters. |
trigger | string — app or dataset | app (the default): the device application calls it. dataset: the plane calls it when records of dataset change, debounceSeconds after the last change, one call per window. |
dataset | string, nullable | For trigger: dataset: the key of one of this project's datasets. 400 BAD_REQUEST for a key the project has not got. |
debounceSeconds | integer 0–300 | For trigger: dataset: the wait after a change for more, before the one call. Default 2. |
key | string, nullable at most 255 characters | WRITE-ONLY, at most 255 characters. Sent as Authorization: Bearer and used to sign every delivery (X-Arda-Signature). Never returned — read hasKey. On PATCH, null or "" clears it. |
verify | boolean | Mint a one-time X-Arda-Verify token per delivery, for POST /v1/webhooks/verify. Default false; no effect while there is no url. JSON true/false (or 1/0) — a string is refused. |
enabled | boolean | Default true. Off, the app's calls are refused on the device and nothing is sent. JSON true/false (or 1/0). |
note | string, nullable at most 255 characters | Whatever helps the next person. At most 255 characters; null clears it. |
slug required | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | What the app says. Lower-case letters, digits, hyphen and underscore, starting with a letter or digit, up to 80. verify is reserved. |
Answer 201 Created
| Field | Type | |
|---|---|---|
webhook | Webhook | An HTTP endpoint of yours that the app on a device may send a message to — Arda.webhook.call(slug, data). The plane makes the call, signs it and records every delivery; the device never holds your key and never talks to your server directly. |
Errors
| Status | Word | When |
|---|---|---|
| 400 | BAD_REQUEST | The body is missing a required field, has one of the wrong type or length, or (on PUT /v1/content/…) arrived shorter or longer than its Content-Length. The message says which. |
| 400 | BAD_KEY_FORMAT | A dataset key or webhook slug is not lower-case letters, digits, - and _, starting with a letter or digit, at most 80 — or is the reserved verify (webhooks). |
| 400 | BAD_URL | The url is not http or https, is over 1024 characters, does not parse, or names localhost, a loopback, private or link-local address. |
| 403 | NO_WEBHOOKS | The account's package does not include webhooks. Reads still work. |
| 403 | LIMIT_REACHED | The package's ceiling on datasets (max_datasets), webhooks (webhook_endpoints), spaces beyond each project's Default (max_spaces) or devices (max_devices), each across the whole account, is reached. |
| 403 | OUT_OF_SCOPE | The key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal. |
| 404 | NO_SPACE | No space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for. |
| 409 | WEBHOOK_EXISTS | A webhook with that slug already exists on the project. |
And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL BAD_JSON TOO_LARGE.
curl -X POST "https://api.ardaform.net/v1/webhooks" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Docs receiver","slug":"zz-docs-receiver","url":"https://ardaform.net/health","key":"whsec_docs_example_key","verify":true}'const res = await fetch('https://api.ardaform.net/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Docs receiver',
slug: 'zz-docs-receiver',
url: 'https://ardaform.net/health',
key: 'whsec_docs_example_key',
verify: true
})
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={
"name": "Docs receiver",
"slug": "zz-docs-receiver",
"url": "https://ardaform.net/health",
"key": "whsec_docs_example_key",
"verify": True,
},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"webhook": {
"slug": "zz-docs-receiver",
"name": "Docs receiver",
"url": "https://ardaform.net/health",
"method": "post",
"trigger": "app",
"dataset": null,
"debounceSeconds": 2,
"hasKey": true,
"verify": true,
"enabled": true,
"note": null,
"createdAt": "2026-10-27T20:10:10.345Z",
"updatedAt": "2026-10-27T20:10:10.345Z"
}
}
No url: a mailbox
curl -X POST "https://api.ardaform.net/v1/webhooks" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Docs mailbox","slug":"zz-docs-mailbox","note":"Check-ins, read by the nightly job"}'const res = await fetch('https://api.ardaform.net/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Docs mailbox',
slug: 'zz-docs-mailbox',
note: 'Check-ins, read by the nightly job'
})
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={
"name": "Docs mailbox",
"slug": "zz-docs-mailbox",
"note": "Check-ins, read by the nightly job",
},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"webhook": {
"slug": "zz-docs-mailbox",
"name": "Docs mailbox",
"url": null,
"method": "post",
"trigger": "app",
"dataset": null,
"debounceSeconds": 2,
"hasKey": false,
"verify": false,
"enabled": true,
"note": "Check-ins, read by the nightly job",
"createdAt": "2026-10-27T20:10:10.248Z",
"updatedAt": "2026-10-27T20:10:10.248Z"
}
}
Past the package's webhook endpoints
curl -X POST "https://api.ardaform.net/v1/webhooks" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"A third","slug":"zz-docs-third"}'const res = await fetch('https://api.ardaform.net/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: 'A third', slug: 'zz-docs-third' })
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={"name": "A third", "slug": "zz-docs-third"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"error": {
"code": "LIMIT_REACHED",
"message": "Your package allows 2 webhook endpoints, and you have 2.",
"limit": 2,
"used": 2
}
}
An address on a private network
curl -X POST "https://api.ardaform.net/v1/webhooks" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"On the LAN","slug":"zz-docs-lan","url":"http://192.168.0.10/hook"}'const res = await fetch('https://api.ardaform.net/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'On the LAN',
slug: 'zz-docs-lan',
url: 'http://192.168.0.10/hook'
})
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={
"name": "On the LAN",
"slug": "zz-docs-lan",
"url": "http://192.168.0.10/hook",
},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"error": {
"code": "BAD_URL",
"message": "A webhook cannot point at a private, loopback or link-local address"
}
}
GET/v1/webhooks/{slug}One webhook
One webhook's definition.
Key scope webhooks:r — read webhooks
Path parameters
| Name | Type | |
|---|---|---|
slug | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The webhook's slug — what your app says in Arda.webhook.call(slug, …), not an id. |
Answer 200 The webhook
| Field | Type | |
|---|---|---|
webhook | Webhook | An HTTP endpoint of yours that the app on a device may send a message to — Arda.webhook.call(slug, data). The plane makes the call, signs it and records every delivery; the device never holds your key and never talks to your server directly. |
Errors
| Status | Word | When |
|---|---|---|
| 403 | OUT_OF_SCOPE | The key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal. |
| 404 | NO_WEBHOOK | No webhook with that slug on the key's project. |
| 404 | NO_SPACE | No space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for. |
And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.
curl "https://api.ardaform.net/v1/webhooks/zz-docs-receiver" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/webhooks/zz-docs-receiver', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/webhooks/zz-docs-receiver",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"webhook": {
"slug": "zz-docs-receiver",
"name": "Docs receiver",
"url": "https://ardaform.net/health",
"method": "post",
"trigger": "app",
"dataset": null,
"debounceSeconds": 2,
"hasKey": true,
"verify": true,
"enabled": true,
"note": null,
"createdAt": "2026-10-27T20:10:10.345Z",
"updatedAt": "2026-10-27T20:10:10.345Z"
}
}
PATCH/v1/webhooks/{slug}Change a webhook
Changes what is sent. "key": null clears the key; "url": null makes the webhook a mailbox; "enabled": false refuses the app's calls on the device until it is switched back on.
Key scope webhooks:w — write webhooks
Path parameters
| Name | Type | |
|---|---|---|
slug | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The webhook's slug — what your app says in Arda.webhook.call(slug, …), not an id. |
Body
| Field | Type | |
|---|---|---|
name | string at least 1 character · at most 120 characters | For people. Trimmed; at most 120 characters. |
url | string, nullable at most 1024 characters | http or https, at most 1024 characters, a PUBLIC host: localhost and loopback, private and link-local addresses are 400 BAD_URL (and refused again by the plane at call time, after the name resolves). Absent, null or "" makes a webhook that only RECORDS its calls (url: null in the answer). |
method | string — post or get or put | post (the default) and put send the envelope as a JSON body; get sends it as query parameters. |
trigger | string — app or dataset | app (the default): the device application calls it. dataset: the plane calls it when records of dataset change, debounceSeconds after the last change, one call per window. |
dataset | string, nullable | For trigger: dataset: the key of one of this project's datasets. 400 BAD_REQUEST for a key the project has not got. |
debounceSeconds | integer 0–300 | For trigger: dataset: the wait after a change for more, before the one call. Default 2. |
key | string, nullable at most 255 characters | WRITE-ONLY, at most 255 characters. Sent as Authorization: Bearer and used to sign every delivery (X-Arda-Signature). Never returned — read hasKey. On PATCH, null or "" clears it. |
verify | boolean | Mint a one-time X-Arda-Verify token per delivery, for POST /v1/webhooks/verify. Default false; no effect while there is no url. JSON true/false (or 1/0) — a string is refused. |
enabled | boolean | Default true. Off, the app's calls are refused on the device and nothing is sent. JSON true/false (or 1/0). |
note | string, nullable at most 255 characters | Whatever helps the next person. At most 255 characters; null clears it. |
slug | string | Accepted only if it is the slug already — anything else is SLUG_IMMUTABLE. |
Answer 200 Saved
| Field | Type | |
|---|---|---|
webhook | Webhook | An HTTP endpoint of yours that the app on a device may send a message to — Arda.webhook.call(slug, data). The plane makes the call, signs it and records every delivery; the device never holds your key and never talks to your server directly. |
Errors
| Status | Word | When |
|---|---|---|
| 400 | SLUG_IMMUTABLE | A PATCH sent a different slug. A slug never changes — your app addresses the webhook by it. |
| 400 | BAD_REQUEST | The body is missing a required field, has one of the wrong type or length, or (on PUT /v1/content/…) arrived shorter or longer than its Content-Length. The message says which. |
| 400 | BAD_URL | The url is not http or https, is over 1024 characters, does not parse, or names localhost, a loopback, private or link-local address. |
| 400 | NOTHING_TO_DO | A PATCH sent no field it can change (a dataset with none of its fields; a device policy with no key). |
| 403 | NO_WEBHOOKS | The account's package does not include webhooks. Reads still work. |
| 403 | OUT_OF_SCOPE | The key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal. |
| 404 | NO_WEBHOOK | No webhook with that slug on the key's project. |
| 404 | NO_SPACE | No space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for. |
And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL BAD_JSON TOO_LARGE.
curl -X PATCH "https://api.ardaform.net/v1/webhooks/zz-docs-receiver" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"note":"Our test receiver","enabled":true}'const res = await fetch('https://api.ardaform.net/v1/webhooks/zz-docs-receiver', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ note: 'Our test receiver', enabled: true })
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.patch(
"https://api.ardaform.net/v1/webhooks/zz-docs-receiver",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={"note": "Our test receiver", "enabled": True},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"webhook": {
"slug": "zz-docs-receiver",
"name": "Docs receiver",
"url": "https://ardaform.net/health",
"method": "post",
"trigger": "app",
"dataset": null,
"debounceSeconds": 2,
"hasKey": true,
"verify": true,
"enabled": true,
"note": "Our test receiver",
"createdAt": "2026-10-27T20:10:10.345Z",
"updatedAt": "2026-10-27T20:10:11.064Z"
}
}
Trying to rename it
curl -X PATCH "https://api.ardaform.net/v1/webhooks/zz-docs-receiver" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"slug":"receiver"}'const res = await fetch('https://api.ardaform.net/v1/webhooks/zz-docs-receiver', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ slug: 'receiver' })
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.patch(
"https://api.ardaform.net/v1/webhooks/zz-docs-receiver",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={"slug": "receiver"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"error": {
"code": "SLUG_IMMUTABLE",
"message": "A webhook slug cannot be changed; your app addresses the webhook by it."
}
}
DELETE/v1/webhooks/{slug}Delete a webhook and its delivery log
Deletes the webhook and its delivery log, and says how many deliveries went. The app's calls to the slug are refused from then on.
Key scope webhooks:d — delete webhooks
Path parameters
| Name | Type | |
|---|---|---|
slug | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The webhook's slug — what your app says in Arda.webhook.call(slug, …), not an id. |
Answer 200 Deleted
| Field | Type | |
|---|---|---|
deleted | object | |
deleted.webhook | string | The slug. |
deleted.deliveries | integer | How many log rows went with it. |
Errors
| Status | Word | When |
|---|---|---|
| 403 | OUT_OF_SCOPE | The key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal. |
| 404 | NO_WEBHOOK | No webhook with that slug on the key's project. |
| 404 | NO_SPACE | No space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for. |
And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.
curl -X DELETE "https://api.ardaform.net/v1/webhooks/zz-docs-mailbox" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/webhooks/zz-docs-mailbox', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.delete(
"https://api.ardaform.net/v1/webhooks/zz-docs-mailbox",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"deleted": {
"webhook": "zz-docs-mailbox",
"deliveries": 1
}
}
POST/v1/webhooks/{slug}/testSend a test delivery now
Makes one delivery now, exactly as a device's call would — same headers, same signature, same log row — with device: null and data: {"test": true, "at": …}. The answer is what your server said.
ok: falsewithstatus: 0is an answer about your server: unreachable, refused, timed out (10 seconds).errorsays which.502 CONNECT_UNAVAILABLEis about ours — nothing was sent; try again shortly. A disabled webhook is409 WEBHOOK_DISABLED.
Key scope webhooks:w — write webhooks
Path parameters
| Name | Type | |
|---|---|---|
slug | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The webhook's slug — what your app says in Arda.webhook.call(slug, …), not an id. |
Answer 200 What happened
| Field | Type | |
|---|---|---|
delivery | object | |
delivery.id | string, nullable | The delivery id, also in the log. |
delivery.ok | boolean | Whether your server answered 2xx (always true with no url). |
delivery.status | integer | Your server's status. 0 when it was never reached; 202 with no url. |
delivery.ms | integer | How long the call took. |
delivery.body | string, nullable | The first 1024 characters of what your server answered. Null when there was none. |
delivery.error | string, nullable | Why status is 0. |
Errors
| Status | Word | When |
|---|---|---|
| 400 | BAD_REQUEST | The body is missing a required field, has one of the wrong type or length, or (on PUT /v1/content/…) arrived shorter or longer than its Content-Length. The message says which. |
| 403 | NO_WEBHOOKS | The account's package does not include webhooks. Reads still work. |
| 403 | OUT_OF_SCOPE | The key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal. |
| 404 | NO_WEBHOOK | No webhook with that slug on the key's project. |
| 404 | NO_SPACE | No space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for. |
| 409 | WEBHOOK_DISABLED | A Test on a webhook that is switched off. |
| 502 | CONNECT_UNAVAILABLE | The device service did not answer — a webhook Test, an activation, or the wipes a space removal needs. Nothing was done; try again in a moment. |
And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.
curl -X POST "https://api.ardaform.net/v1/webhooks/zz-docs-receiver/test" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/webhooks/zz-docs-receiver/test', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/webhooks/zz-docs-receiver/test",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"delivery": {
"id": "dlv_4233a8826b4d795b",
"ok": true,
"status": 200,
"ms": 104,
"body": "ok\n",
"error": null
}
}
On a mailbox
curl -X POST "https://api.ardaform.net/v1/webhooks/zz-docs-mailbox/test" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/webhooks/zz-docs-mailbox/test', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/webhooks/zz-docs-mailbox/test",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"delivery": {
"id": "dlv_89e7996d7812e06c",
"ok": true,
"status": 202,
"ms": 0,
"body": null,
"error": null
}
}
GET/v1/webhooks/{slug}/deliveriesThe delivery log — newest first, or a feed after one delivery
One row per call: when, which device, what your server answered and how long it took. Kept for 500 deliveries or seven days per webhook, and not counted against storage.
- Without
after, newest first — the log as a person reads it. - With
after=dlv_…, the deliveries recorded after that one, oldest first: a feed. Keep the id of the last one you handled and ask again; you never miss one or see one twice. A delivery joins the feed once it is two seconds old, so two calls finishing at once cannot leave a gap behind your cursor. - An
afterthe log no longer holds is404 NO_DELIVERY, not an empty page: you fell behind by more than the log keeps. include=payloadadds each call's envelope — the app'sdata— and caps a page at 50.
Key scope webhooks:r — read webhooks
Path parameters
| Name | Type | |
|---|---|---|
slug | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The webhook's slug — what your app says in Arda.webhook.call(slug, …), not an id. |
Query parameters
| Name | Type | |
|---|---|---|
limit | integer 1–200 · default 50 | 1–200, default 50 — at most 50 with include=payload. More is cut to the cap; 0 or not a number is 50. |
include | string — payload | payload puts the envelope on every delivery. Any other value is 400 BAD_PARAM. |
after | string pattern ^dlv_[0-9a-f]{16}$ | A delivery id of this webhook. Answers the deliveries recorded after it, oldest first. Not of the form: 400 BAD_PARAM; not this webhook's or pruned: 404 NO_DELIVERY. |
Answer 200 The log
| Field | Type | |
|---|---|---|
webhook | string | The slug. |
limit | integer | The page size used. |
hasMore | boolean | There are more beyond this page — older ones without after, newer ones ready now with it. |
deliveries | array of Webhook delivery |
Errors
| Status | Word | When |
|---|---|---|
| 400 | BAD_PARAM | A query parameter the endpoint does not take, or a value it does not know. Refused rather than ignored, so a typo in a filter never returns everything. |
| 403 | OUT_OF_SCOPE | The key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal. |
| 404 | NO_WEBHOOK | No webhook with that slug on the key's project. |
| 404 | NO_DELIVERY | No such delivery: not this webhook's, never made, older than the log keeps (500 rows or seven days), or — for /verify — older than 24 hours. |
| 404 | NO_SPACE | No space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for. |
And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.
curl "https://api.ardaform.net/v1/webhooks/zz-docs-receiver/deliveries?limit=20" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/webhooks/zz-docs-receiver/deliveries?limit=20', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/webhooks/zz-docs-receiver/deliveries?limit=20",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"webhook": "zz-docs-receiver",
"limit": 20,
"hasMore": false,
"deliveries": [
{
"id": "dlv_4233a8826b4d795b",
"at": "2026-10-27T20:10:11.445Z",
"device": null,
"status": 200,
"ok": true,
"ms": 104,
"error": null,
"verified": false,
"verifiedAt": null
}
]
}
With include=payload, on the mailbox
curl "https://api.ardaform.net/v1/webhooks/zz-docs-mailbox/deliveries?include=payload" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/webhooks/zz-docs-mailbox/deliveries?include=payload', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/webhooks/zz-docs-mailbox/deliveries?include=payload",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"webhook": "zz-docs-mailbox",
"limit": 50,
"hasMore": false,
"deliveries": [
{
"id": "dlv_89e7996d7812e06c",
"at": "2026-10-27T20:10:11.257Z",
"device": null,
"status": 202,
"ok": true,
"ms": 0,
"error": null,
"verified": false,
"verifiedAt": null,
"payload": {
"id": "dlv_89e7996d7812e06c",
"webhook": "zz-docs-mailbox",
"project": "api-test",
"device": null,
"at": "2026-10-27T20:10:11.257Z",
"verify": false,
"data": {
"test": true,
"at": "2026-10-27T20:10:11.257Z"
}
}
}
]
}
After the last one read: nothing new yet
curl "https://api.ardaform.net/v1/webhooks/zz-docs-mailbox/deliveries?include=payload&after=dlv_89e7996d7812e06c" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/webhooks/zz-docs-mailbox/deliveries?include=payload&after=dlv_89e7996d7812e06c', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/webhooks/zz-docs-mailbox/deliveries?include=payload&after=dlv_89e7996d7812e06c",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"webhook": "zz-docs-mailbox",
"limit": 50,
"hasMore": false,
"deliveries": []
}
GET/v1/webhooks/{slug}/deliveries/{id}One delivery, with what was sent and what came back
One delivery with what was sent (payload, the envelope) and the start of what came back (response, 1024 characters). The id is the X-Arda-Delivery your server saw.
Key scope webhooks:r — read webhooks
Path parameters
| Name | Type | |
|---|---|---|
slug | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The webhook's slug — what your app says in Arda.webhook.call(slug, …), not an id. |
id | string pattern ^dlv_[0-9a-f]{16}$ | The delivery id — dlv_ and sixteen hex characters: the X-Arda-Delivery your server saw, and the id in the log. Anything else is 404 NO_DELIVERY. |
Answer 200 The delivery
| Field | Type | |
|---|---|---|
delivery | Webhook delivery | One call the plane made to your server — the same id your server saw as X-Arda-Delivery — or, on a webhook with no url, one call it recorded instead of sending. The plane keeps the most recent ones per webhook (500 rows or seven days), never counted against your storage. |
Errors
| Status | Word | When |
|---|---|---|
| 400 | BAD_PARAM | A query parameter the endpoint does not take, or a value it does not know. Refused rather than ignored, so a typo in a filter never returns everything. |
| 403 | OUT_OF_SCOPE | The key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal. |
| 404 | NO_WEBHOOK | No webhook with that slug on the key's project. |
| 404 | NO_DELIVERY | No such delivery: not this webhook's, never made, older than the log keeps (500 rows or seven days), or — for /verify — older than 24 hours. |
| 404 | NO_SPACE | No space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for. |
And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.
curl "https://api.ardaform.net/v1/webhooks/zz-docs-receiver/deliveries/dlv_4233a8826b4d795b" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/webhooks/zz-docs-receiver/deliveries/dlv_4233a8826b4d795b', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/webhooks/zz-docs-receiver/deliveries/dlv_4233a8826b4d795b",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"delivery": {
"id": "dlv_4233a8826b4d795b",
"at": "2026-10-27T20:10:11.445Z",
"device": null,
"status": 200,
"ok": true,
"ms": 104,
"error": null,
"verified": false,
"verifiedAt": null,
"payload": {
"id": "dlv_4233a8826b4d795b",
"webhook": "zz-docs-receiver",
"project": "api-test",
"device": null,
"at": "2026-10-27T20:10:11.341Z",
"verify": true,
"data": {
"test": true,
"at": "2026-10-27T20:10:11.341Z"
}
},
"response": "ok\n"
}
}
A mailbox
A webhook with no url needs no server of yours that the internet can reach: every call the app makes is recorded, and your system collects them when it likes. Read the feed from the last id you handled, oldest first, and store the cursor after the work is done:
# the first time: the newest page, newest first
curl "https://api.ardaform.net/v1/webhooks/checkins/deliveries?include=payload" \
-H "Authorization: Bearer $ARDA_KEY"
# every time after: what came after the last id you handled, oldest first
curl "https://api.ardaform.net/v1/webhooks/checkins/deliveries?include=payload&after=dlv_48e7396320508859" \
-H "Authorization: Bearer $ARDA_KEY"const URL = 'https://api.ardaform.net/v1/webhooks/checkins/deliveries'
const auth = { Authorization: `Bearer ${process.env.ARDA_KEY}` }
// after: the id of the last delivery you handled, or null the first time
export async function drain(after, handle) {
for (;;) {
const q = new URLSearchParams({ include: 'payload', limit: '50' })
if (after) q.set('after', after)
const res = await fetch(`${URL}?${q}`, { headers: auth })
const page = await res.json()
if (res.status === 404 && page.error?.code === 'NO_DELIVERY') {
after = null // fell behind the log: start from the newest
continue
}
if (!res.ok) throw new Error(page.error.code)
// without `after` the page is newest first; with it, oldest first
const list = after ? page.deliveries : [...page.deliveries].reverse()
for (const d of list) {
await handle(d.payload.data, d.payload.device, d.id) // key your work on d.id
after = d.id // save it after the work, not before
}
if (!page.hasMore || !list.length) return after
}
}import os
import requests
URL = "https://api.ardaform.net/v1/webhooks/checkins/deliveries"
AUTH = {"Authorization": f"Bearer {os.environ['ARDA_KEY']}"}
def drain(after, handle):
"""after: the id of the last delivery you handled, or None the first time."""
while True:
params = {"include": "payload", "limit": 50}
if after:
params["after"] = after
r = requests.get(URL, params=params, headers=AUTH)
page = r.json()
if r.status_code == 404 and page["error"]["code"] == "NO_DELIVERY":
after = None # fell behind the log: start from the newest
continue
r.raise_for_status()
rows = page["deliveries"] if after else list(reversed(page["deliveries"]))
for d in rows:
handle(d["payload"]["data"], d["payload"]["device"], d["id"])
after = d["id"]
if not page["hasMore"] or not rows:
return afterA pattern rather than a captured call: not run as a whole; each request in it is the one above, which was. The log keeps 500 deliveries or seven days — a mailbox is a queue to drain, not an archive.
POST/v1/webhooks/verifyProve a delivery came from ArdaForm
For your server, on a webhook with verify on: send back the X-Arda-Delivery id and the X-Arda-Verify token a delivery carried, and learn whether ArdaForm really made that call. Works for 24 hours, and only with a key of the same project.
{"valid": false}is an answer: that call did not come from ArdaForm.- With
valid: true,verifiedAtis from before this call:nullthe first time a token is presented, a time when it was presented already — a retry of yours, or a replay — and your server decides. - A
valid: trueanswer needs the token from a real delivery, which only your server sees; the examples could not capture one. Its fields are in the table.
Key scope webhooks:r — read webhooks
Body
| Field | Type | |
|---|---|---|
delivery required | string pattern ^dlv_[0-9a-f]{16}$ | The X-Arda-Delivery header. |
token required | string at least 1 character | The X-Arda-Verify header, exactly as it arrived. |
Answer 200 The verdict
| Field | Type | |
|---|---|---|
valid | boolean | Whether the token matches that delivery. False is an answer: the plane did not make that call. The other fields are present only when true. |
webhook | string | The slug. |
device | object, nullable | The device whose app sent it, {uid, name}, or null for a Test. |
device.uid | string | The device's public id. |
device.name | string | The device's name. |
at | string (date-time) | When the plane made the call. |
status | integer | The HTTP status your server answered, as the plane recorded it. |
verifiedAt | string (date-time), nullable | When this token was FIRST verified, from before this call — null the first time. |
Errors
| Status | Word | When |
|---|---|---|
| 400 | BAD_REQUEST | The body is missing a required field, has one of the wrong type or length, or (on PUT /v1/content/…) arrived shorter or longer than its Content-Length. The message says which. |
| 400 | NOT_VERIFIABLE | The delivery's webhook has verify off (or no url), so the delivery carried no token. |
| 403 | OUT_OF_SCOPE | The key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal. |
| 404 | NO_DELIVERY | No such delivery: not this webhook's, never made, older than the log keeps (500 rows or seven days), or — for /verify — older than 24 hours. |
| 404 | NO_SPACE | No space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for. |
And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL BAD_JSON TOO_LARGE.
A token that does not match
curl -X POST "https://api.ardaform.net/v1/webhooks/verify" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"delivery":"dlv_4233a8826b4d795b","token":"0000000000000000000000000000000000000000000000000000000000000000"}'const res = await fetch('https://api.ardaform.net/v1/webhooks/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
delivery: 'dlv_4233a8826b4d795b',
token: '0000000000000000000000000000000000000000000000000000000000000000'
})
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/webhooks/verify",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={
"delivery": "dlv_4233a8826b4d795b",
"token": "0000000000000000000000000000000000000000000000000000000000000000",
},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"valid": false
}
A delivery of a webhook without verify
curl -X POST "https://api.ardaform.net/v1/webhooks/verify" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"delivery":"dlv_89e7996d7812e06c","token":"0000000000000000000000000000000000000000000000000000000000000000"}'const res = await fetch('https://api.ardaform.net/v1/webhooks/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
delivery: 'dlv_89e7996d7812e06c',
token: '0000000000000000000000000000000000000000000000000000000000000000'
})
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/webhooks/verify",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={
"delivery": "dlv_89e7996d7812e06c",
"token": "0000000000000000000000000000000000000000000000000000000000000000",
},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"error": {
"code": "NOT_VERIFIABLE",
"message": "Webhook 'zz-docs-mailbox' has verify switched off, so its deliveries carry no token.",
"webhook": "zz-docs-mailbox"
}
}
An id ArdaForm never sent
curl -X POST "https://api.ardaform.net/v1/webhooks/verify" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"delivery":"dlv_0000000000000000","token":"0000000000000000000000000000000000000000000000000000000000000000"}'const res = await fetch('https://api.ardaform.net/v1/webhooks/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
delivery: 'dlv_0000000000000000',
token: '0000000000000000000000000000000000000000000000000000000000000000'
})
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/webhooks/verify",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={
"delivery": "dlv_0000000000000000",
"token": "0000000000000000000000000000000000000000000000000000000000000000",
},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"error": {
"code": "NO_DELIVERY",
"message": "No delivery with that id in this project in the last 24 hours."
}
}
Fired by a dataset
A webhook does not have to wait for the app. Create or patch one with "trigger": "dataset", the key of one of the project's datasets as dataset, and a debounceSeconds (default 2), and the plane calls it whenever records of that dataset change — a device pushed some, this API wrote some — waiting the given seconds for more and making one call for the whole window. It is an ordinary delivery: the same envelope, signature, limits and delivery log; device is null and data says what moved.
"data": {"event": "dataset.changed", "dataset": "orders", "name": "Orders",
"records": 3, "devices": ["7f3a…"], "since": "2026-10-25T20:45:50.544Z", "until": "2026-10-25T20:45:51.547Z"}
records is how many moved (0 when a writer did not say), devices the devices that pushed (empty for this API's writes), since and until the window. A call the account's per-minute limit refuses is dropped, not queued: the next change opens a new window, and your server can always read the dataset itself. Deleting the dataset leaves the webhook silent (dataset: null); re-point it with a PATCH.
The shapes
Webhook
An HTTP endpoint of yours that the app on a device may send a message to — Arda.webhook.call(slug, data). The plane makes the call, signs it and records every delivery; the device never holds your key and never talks to your server directly.
| Field | Type | |
|---|---|---|
slug | string | How the app addresses it, and what your code says. Lower-case letters, digits, hyphen and underscore, starting with a letter or digit. FROZEN after create — verify is reserved. |
name | string | For people. Change it freely. |
url | string, nullable | Where the plane sends. http or https on a public host: localhost and loopback, link-local and private addresses are refused when saved, and refused again by the plane at call time after the name resolves. NULL FOR A WEBHOOK THAT ONLY RECORDS ITS CALLS: nothing is sent, the app is answered ok with status 202, and every call is a delivery whose envelope …/deliveries hands back — a mailbox your own systems poll. |
method | string — post or get or put | How it is sent. post and put carry the envelope as a JSON body; get carries the same fields as query parameters. |
trigger | string — app or dataset | What makes the call. app: the device application calls it (Arda.webhook.call). dataset: THE PLANE calls it when records of dataset change — a device pushed some, the API wrote some — debounceSeconds after the last change, ONE call for every change in that window; device is null in the envelope and data is {event: "dataset.changed", dataset, name, records, devices, since, until}. |
dataset | string, nullable | For trigger: dataset: the key of the dataset watched, one of this project's. Null for an app webhook — and null when the dataset it watched was deleted, which leaves the webhook silent. |
debounceSeconds | integer | For trigger: dataset: how long the plane waits after a change for more before the one call. 0 to 300; default 2. |
hasKey | boolean | Whether a key is set. THE KEY ITSELF IS NEVER RETURNED: set it with key on create or PATCH, clear it with "key": null. When set, every delivery carries it as Authorization: Bearer and is signed with it (X-Arda-Signature). |
verify | boolean | Mint a one-time X-Arda-Verify token per delivery, which your server hands back to POST /v1/webhooks/verify to prove the call came from here. Off by default. No effect on a webhook with no url — nothing is sent, so there is nobody to hand a token to. |
enabled | boolean | Off, and the app's call is refused on the device and nothing is sent. The delivery log is kept. |
note | string, nullable | Optional. Whatever helps the next person. |
createdAt | string (date-time) | When it was made. UTC. |
updatedAt | string (date-time) | When its definition last changed. A delivery does not move it. UTC. |
Webhook delivery
One call the plane made to your server — the same id your server saw as X-Arda-Delivery — or, on a webhook with no url, one call it recorded instead of sending. The plane keeps the most recent ones per webhook (500 rows or seven days), never counted against your storage.
| Field | Type | |
|---|---|---|
id | string | The delivery id: dlv_ and sixteen hex characters. What X-Arda-Delivery and the envelope's id carried, and what /verify takes. |
at | string (date-time) | When the plane made the call. UTC. |
device | object, nullable | The device whose app sent it, or null for a Test run from the portal or from POST /v1/webhooks/{slug}/test. |
device.uid | string | The device's public id, as the portal shows it. |
device.name | string | The device's name. |
status | integer | The HTTP status your server answered. 0 means it was never reached — error says why. On a webhook with no url it is always 202: recorded, nothing sent. |
ok | boolean | Whether status was 2xx. |
ms | integer | How long the call took, in milliseconds. A timeout counts the whole wait. |
error | string, nullable | Why status is 0: a refused address, a timeout, TLS, a connection fault. Null when your server answered. |
verified | boolean | Whether your server has presented this delivery's token to /verify and been answered valid: true. Always false for a webhook with verify off. |
verifiedAt | string (date-time), nullable | When that first happened, or null. |
payload | object, nullable | The envelope as the plane built it — what your server was sent, or what a webhook with no url recorded: id, webhook, project, device, at, verify and data, the app's own JSON untouched. ONLY on GET …/deliveries/{id}, and on the list with include=payload. Null for a delivery recorded before 2026-10-06, which kept none. |
response | string, nullable | The first 1024 characters of what your server answered. ONLY on GET …/deliveries/{id}. Null when it answered nothing, was never reached, or the webhook has no url. |