{
  "page": "docs/webhooks",
  "title": "Webhooks and notifications",
  "slug": "webhooks",
  "description": "One event list, delivered as signed webhooks on every plan and emailed to owners where a person should know.",
  "section": "Running an org",
  "updated": "2026-09-30",
  "blocks": [
    {
      "type": "p",
      "text": "Arettic has one list of events. Each event is sent to your webhook endpoints, signed, and retried for 24 hours until your server answers 2xx. Some are also emailed to the org's owners. Webhooks are on every plan, and so is the list of recent events."
    },
    {
      "type": "h2",
      "id": "endpoints",
      "text": "Add an endpoint"
    },
    {
      "type": "p",
      "text": "`POST https://api.arettic.com/v1/orgs/{orgId}/webhooks` as an owner (or with a write org key), or on the Webhooks page of the dashboard. Send `url`, and optionally `events`: a list of event types to receive. No `events`, or an empty list, means every event."
    },
    {
      "type": "list",
      "items": [
        "The answer carries the endpoint's signing secret (`whsec_…`) **once**. Store it now; it isn't shown again. It is kept encrypted with your org's key.",
        "In production the URL must be `https` and a public address (not a private or local network). `http://localhost` works while you develop against a local Arettic.",
        "Up to 10 endpoints per org; the 11th gets `402 plan_limit`.",
        "`DELETE /v1/orgs/{orgId}/webhooks/{id}` removes one; `GET /v1/orgs/{orgId}/webhooks` lists them with the event types you can pick."
      ]
    },
    {
      "type": "code",
      "title": "curl",
      "lang": "bash",
      "code": "curl https://api.arettic.com/v1/orgs/$ORG_ID/webhooks \\\n  -H \"Authorization: Bearer $ARETTIC_ORG_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"url\": \"https://example.com/arettic-hook\", \"events\": [\"job.completed\", \"dispute.decided\"] }'"
    },
    {
      "type": "code",
      "title": "Response",
      "lang": "json",
      "code": "{\n  \"webhook\": {\n    \"id\": \"0b8e2c4d-6f1a-4e3b-9c5d-7e8f9a0b1c2d\",\n    \"secret\": \"whsec_4f1c…\",\n    \"url\": \"https://example.com/arettic-hook\",\n    \"events\": [\"job.completed\", \"dispute.decided\"]\n  },\n  \"note\": \"Store the secret now: it isn't shown again.\"\n}"
    },
    {
      "type": "h2",
      "id": "events",
      "text": "Events"
    },
    {
      "type": "table",
      "caption": "Every event type and its data",
      "head": [
        "Type",
        "When",
        "`data` fields",
        "Emailed to owners"
      ],
      "rows": [
        [
          "`balance.low`",
          "The org's balance fell under its low-balance alert (default $5; set it with `PUT /v1/orgs/{orgId}/notifications` and `low_balance_usd`). Once per top-up.",
          "`balance_credits`, `balance_usd`",
          "yes"
        ],
        [
          "`budget.80pct`",
          "A live agent has spent 80% of its monthly budget. Once per agent per month.",
          "`agent_id`, `agent_name`, `spent_credits`, `budget_credits`",
          "yes"
        ],
        [
          "`approval.requested`",
          "A purchase is waiting for an owner's approval.",
          "`approval_id`, `agent_id`, `tool_id`, `item_count`, `amount_credits`, `amount_usd`, `reason` (`over_threshold` or `over_budget`), `expires_at`",
          "no (owners get their own email with a one-click link)"
        ],
        [
          "`approval.decided`",
          "An owner approved or rejected it.",
          "`approval_id`, `agent_id`, `status` (`approved` or `rejected`), `note`",
          "no"
        ],
        [
          "`job.completed`",
          "A job (more than 25 items) finished.",
          "`job_id`, `status`, `receipt_id`, `item_count`, `passed`, `expired`",
          "no"
        ],
        [
          "`tool.paused`",
          "A tool your org used in the last 30 days was paused.",
          "`tool_id`, `tool_name`, `reason`",
          "yes"
        ],
        [
          "`dispute.decided`",
          "One of your disputes was decided.",
          "`dispute_id`, `receipt_id`, `item_index`, `decision` (`upheld` or `rejected`), `refunded_credits`, `note`",
          "yes"
        ],
        [
          "`price.changed`",
          "The price per success of a tool your org used in the last 30 days changed.",
          "`tool_id`, `tool_name`, `old_credits`, `new_credits`, `valid_from`",
          "yes"
        ],
        [
          "`trial.expiring`",
          "Trial credits expire within 7 days. Once per grant.",
          "`credits`, `expires_on`",
          "yes"
        ],
        [
          "`webhook.test`",
          "You asked for a test delivery (below).",
          "`message`",
          "no"
        ]
      ]
    },
    {
      "type": "p",
      "text": "`GET https://api.arettic.com/v1/orgs/{orgId}/events` lists recent events, whether or not you have endpoints: a way to catch up after downtime."
    },
    {
      "type": "h2",
      "id": "delivery",
      "text": "The delivery"
    },
    {
      "type": "p",
      "text": "Each delivery is a `POST` with a JSON body and these headers:"
    },
    {
      "type": "table",
      "caption": "Delivery headers",
      "head": [
        "Header",
        "Value"
      ],
      "rows": [
        [
          "`Content-Type`",
          "`application/json`"
        ],
        [
          "`User-Agent`",
          "`Arettic-Webhooks/1.0`"
        ],
        [
          "`Arettic-Event-Id`",
          "The event's id: the same on every retry, so you can drop duplicates."
        ],
        [
          "`Arettic-Event-Type`",
          "The event type, e.g. `job.completed`."
        ],
        [
          "`Arettic-Signature`",
          "`t=<unix seconds>,v1=<hex HMAC-SHA256>` (below)."
        ]
      ]
    },
    {
      "type": "code",
      "title": "job.completed (example values)",
      "lang": "json",
      "code": "{\n  \"id\": \"6a1d3f5b-2c4e-4d6f-8a1b-3c5d7e9f1a2b\",\n  \"type\": \"job.completed\",\n  \"created_at\": \"2026-09-30T11:04:52.000Z\",\n  \"data\": {\n    \"job_id\": \"d3b07384-d9a0-4c9b-8f1e-2a5c7e9b1d3f\",\n    \"status\": \"done\",\n    \"receipt_id\": \"9e107d9d-372b-4b6a-8a1f-3c5d7e9f1a2b\",\n    \"item_count\": 200,\n    \"passed\": 181,\n    \"expired\": 0\n  }\n}"
    },
    {
      "type": "code",
      "title": "approval.requested (example values)",
      "lang": "json",
      "code": "{\n  \"id\": \"1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b\",\n  \"type\": \"approval.requested\",\n  \"created_at\": \"2026-09-30T11:10:00.000Z\",\n  \"data\": {\n    \"approval_id\": \"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d\",\n    \"agent_id\": \"b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e\",\n    \"tool_id\": \"peopledatalabs-enrich-company\",\n    \"item_count\": 400,\n    \"amount_credits\": \"24000\",\n    \"amount_usd\": \"24.000\",\n    \"reason\": \"over_threshold\",\n    \"expires_at\": \"2026-10-01T11:10:00.000Z\"\n  }\n}"
    },
    {
      "type": "h2",
      "id": "signatures",
      "text": "Verify the signature"
    },
    {
      "type": "p",
      "text": "`Arettic-Signature` is `t=<unix seconds>,v1=<signature>`, where the signature is the hex HMAC-SHA256, keyed with your endpoint's secret, of the string `<t>.<raw body>`. Verify it against the **raw** body, before parsing the JSON, and reject a `t` more than 5 minutes from your clock (it stops replays). Compare in constant time."
    },
    {
      "type": "code",
      "title": "Node / TypeScript",
      "lang": "ts",
      "code": "import { createHmac, timingSafeEqual } from \"node:crypto\";\n\nexport function verifyArettic(secret: string, rawBody: string, header: string): boolean {\n  const parts = Object.fromEntries(header.split(\",\").map((p) => p.split(\"=\") as [string, string]));\n  const t = Number(parts.t);\n  if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;\n  const expected = createHmac(\"sha256\", secret).update(`${t}.${rawBody}`).digest(\"hex\");\n  const a = Buffer.from(expected);\n  const b = Buffer.from(parts.v1);\n  return a.length === b.length && timingSafeEqual(a, b);\n}"
    },
    {
      "type": "code",
      "title": "Python",
      "lang": "python",
      "code": "import hashlib\nimport hmac\nimport time\n\n\ndef verify_arettic(secret: str, raw_body: bytes, header: str) -> bool:\n    parts = dict(p.split(\"=\", 1) for p in header.split(\",\"))\n    try:\n        t = int(parts[\"t\"])\n    except (KeyError, ValueError):\n        return False\n    if \"v1\" not in parts or abs(time.time() - t) > 300:\n        return False\n    signed = f\"{t}.\".encode() + raw_body\n    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()\n    return hmac.compare_digest(expected, parts[\"v1\"])"
    },
    {
      "type": "h2",
      "id": "retries",
      "text": "Retries"
    },
    {
      "type": "p",
      "text": "Any 2xx answer within 10 seconds counts as delivered. Anything else (another status, a timeout, a redirect, a refused connection) is retried after 1, 5, 15, 30, 60, 120, 240, 480 and 480 minutes: ten tries over about 24 hours. After that, or once the next try would fall past 24 hours from the event, the delivery is marked failed. Redirects are never followed."
    },
    {
      "type": "p",
      "text": "Deliveries can arrive out of order and, rarely, more than once. Use `Arettic-Event-Id` to drop duplicates, and the event's `created_at` or the object it points to (fetch the job, the receipt, the dispute) for the current state."
    },
    {
      "type": "h2",
      "id": "debugging",
      "text": "Test and debug"
    },
    {
      "type": "list",
      "items": [
        "`POST /v1/orgs/{orgId}/webhooks/{id}/test` sends a `webhook.test` event to that one endpoint straight away and answers with the delivery's `status`, `last_status_code` and `last_error`.",
        "`GET /v1/orgs/{orgId}/webhook-deliveries` lists recent deliveries: status, attempts, the last status code or error, and when the next try is.",
        "The Webhooks page of the dashboard shows both, and the SDKs have `org.webhooks.create`, `.list`, `.test` and `.delete`."
      ]
    },
    {
      "type": "h2",
      "id": "schema",
      "text": "Schema"
    },
    {
      "type": "p",
      "text": "The event envelope and every event's `data` are published as JSON Schema at [/schemas/webhook-event.json](/schemas/webhook-event.json)."
    }
  ]
}