Developer documentation
Send events to Doqet, receive webhooks and verify that they came from us.
Quick start
Your systems send events to one HTTPS endpoint per source. Rules and destinations are set up in the console.
- Create a source and copy its token. The token is shown only once.
- Add a destination, then a rule that routes events to it.
- Send an event to the source's ingest endpoint. The Sources page in the console shows its address, with a copy button. The example reads that address from
DOQET_INGEST_URL.
curl -X POST "$DOQET_INGEST_URL" \
-H "Authorization: Bearer $DOQET_SOURCE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8841-paid" \
-d '{
"type": "order.paid",
"title": "Order 8841 paid",
"priority": "high",
"labels": { "region": "eu" },
"data": { "order_id": "8841", "amount": 299 }
}'Send an event
Send POST /v1/ingest/{source_id} with a JSON body and Authorization: Bearer <source token>. A 202 Accepted response means the event is stored.
| Field | Type | Default | Notes |
|---|---|---|---|
type | string | generic | Up to 200 bytes. Any name you choose. |
title | string | Up to 1,000 bytes. | |
body | string | Counts toward the total event size. | |
priority | string | normal | Up to 100 bytes. Any value your rules can match. |
labels | object | {} | Free-form values for rules, such as labels.environment. |
data | object | {} | Free-form values for rules, such as data.order_id. |
dedup_key | string | Up to 512 bytes. Identifies repeats for rules that deduplicate. | |
expires_at | RFC 3339 time | 24 hours after receipt | In the future and at most 30 days ahead. |
An event needs a title, a body or a non-empty data object. Unknown fields are rejected, and numbers keep their exact value.
Response
The response has the event's id, its status and whether it was a duplicate. A new event's status is one of pending (queued for delivery), batched (waiting in an aggregation window), unmatched (no rule applied) or suppressed (a rule treated it as a repeat).
Idempotency
Send an Idempotency-Key header so that retrying a request is safe. Use a value of up to 255 bytes that identifies the event in your system, such as order-8841-paid.
- The same key and body within 24 hours returns
202with the original event'sidandduplicate: true. Nothing is queued again. - The same key with a different body returns
409 idempotency_key_reused. - Keys belong to one source, so another source can use the same key.
- After 24 hours the key is forgotten and the request creates a new event.
Idempotency-Key protects ingestion from your own retries. A rule's deduplication is separate: it suppresses repeat events on purpose, within a window you choose.
Rate and size limits
| Limit | Value | When exceeded |
|---|---|---|
| Request body | 64 KiB | 413 payload_too_large |
| Stored event after normalization | 128 KiB | 413 payload_too_large |
| Each number | 1,024 digits, exponent from -1024 to 1024 | 400 invalid_request |
| Events per minute, per workspace | Set by your plan | 429 quota_exceeded with Retry-After |
| Retained events and storage | Set by your plan | 429 quota_exceeded |
Settings, then Usage, shows your workspace's current limits. A rejected request stores nothing and doesn't count toward usage.
Error codes
Errors share one JSON shape. code is stable and safe to branch on. message is English text for people and can change.
{
"error": {
"code": "quota_exceeded",
"message": "Workspace capacity reached: events_per_minute",
"limit": "events_per_minute"
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed JSON, an unknown field or a number out of range. |
| 400 | event_content_required | The event has no title, body or data. |
| 400 | event_field_too_long | title, type, priority or dedup_key is over its limit. |
| 400 | event_expiry_invalid | expires_at isn't within the next 30 days. |
| 400 | idempotency_key_too_long | Idempotency-Key is over 255 bytes. |
| 401 | unauthorized | The token doesn't match this source. |
| 403 | source_disabled | The source is disabled or archived. |
| 409 | idempotency_key_reused | The key was already used with different content. |
| 413 | payload_too_large | The request or the stored event is over its size limit. |
| 429 | quota_exceeded | A workspace limit was reached. limit names it. |
| 500 | internal_error | Something failed on our side. Retry with the same Idempotency-Key. |
Retry 429 and 5xx responses and network errors with the same Idempotency-Key. Wait for Retry-After when it is present. Fix other 4xx responses before sending again.
Webhook payloads
A webhook destination receives one POST for each delivery. The endpoint must be HTTPS on a public address. Doqet doesn't follow redirects. It waits up to 10 seconds for your response headers and 15 seconds for the whole request.
Single event
The body is your event plus its id, source_id and created_at.
{
"id": "evt_5a1e7c3f9b2d4e68",
"source_id": "src_8d2b6f4a1c7e4093",
"type": "order.paid",
"title": "Order 8841 paid",
"body": "",
"priority": "high",
"labels": { "region": "eu" },
"data": { "order_id": "8841", "amount": 299 },
"created_at": "2026-11-01T09:30:00Z",
"expires_at": "2026-11-02T09:30:00Z"
}Batch
A rule with an aggregation window sends one request for each group. Each entry in events has the same fields as a single event. One request holds at most 100 events.
{
"rule_id": "rul_3f0c1a9e7b5d4c26",
"count": 2,
"events": [
{ "id": "evt_5a1e7c3f9b2d4e68", "type": "server.disk.warning", "title": "Disk at 91%" },
{ "id": "evt_7b3d9e1f2c4a6058", "type": "server.disk.warning", "title": "Disk at 94%" }
]
}Your response
Any 2xx response counts as delivered and the response body is ignored. The body is fixed at the first attempt, so every retry sends the same bytes.
Webhook headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Doqet/ followed by the release version, for example Doqet/1.0.0. |
X-Doqet-Delivery-ID | Identifies this delivery and stays the same across retries. |
X-Doqet-Event-ID | The event. For a batch, the first event. |
X-Doqet-Attempt | The attempt number, starting at 1. |
X-Doqet-Timestamp | Unix time in seconds when this attempt was sent. |
X-Doqet-Signature | v1= and a hex HMAC. Sent for the JSON format when the destination has a signing secret. |
X-Doqet-Test | true on a test message sent from the console. Real deliveries never carry it. |
Authorization | Bearer <token>. Sent when the destination has a bearer token. |
Verify signatures
Set a signing secret on a webhook destination and every request carries X-Doqet-Signature. Verify it before you trust a request.
- Read the raw request body as bytes. Parsing and re-serializing JSON changes them.
- Join
X-Doqet-Timestamp, a period,X-Doqet-Delivery-ID, a period and the raw body. - Compute HMAC-SHA256 of that value with your signing secret, as lowercase hex, and prefix
v1=. - Compare the result with
X-Doqet-Signaturein constant time. - Reject old timestamps. Each attempt is signed with its own timestamp, so a five-minute window doesn't block retries.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
// rawBody must be the exact bytes received, before any JSON parsing.
export function verifyDoqetSignature(
{ secret, timestamp, deliveryId, signature, rawBody },
now = Date.now() / 1000,
) {
if (!/^[0-9]+$/.test(timestamp ?? "")) return false;
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected =
"v1=" +
createHmac("sha256", secret)
.update(`${timestamp}.${deliveryId}.`)
.update(rawBody)
.digest("hex");
const given = Buffer.from(signature ?? "");
return (
given.length === expected.length &&
timingSafeEqual(given, Buffer.from(expected))
);
}Retries and expiry
Doqet retries a delivery automatically when it could still succeed. Delays grow with each attempt, from 10 seconds to 8 hours, and include random jitter.
| Result | What happens |
|---|---|
| 2xx | Delivered. |
| Network error or timeout | Retried. |
| 408, 429 or 5xx | Retried. Retry-After is honoured, up to 6 hours. |
| Any other status | Failed, with no automatic retry. |
By default a delivery gets up to 10 attempts. The deployment sets that limit, not each destination. Those attempts span about 16 to 20 hours. After that it is marked failed, and you can retry it from the event's page until it expires.
Expiry
Every event expires, 24 hours after receipt by default or at expires_at. No attempt starts after expiry, and a retry that would fall after it isn't scheduled. Set expires_at to match how long the notification stays useful. A batch expires with its earliest event.
At-least-once delivery
Delivery is at-least-once. There is no exactly-once guarantee between Doqet and your endpoint.
A repeat happens when Doqet can't tell whether a request worked. For example, your endpoint may process an event and lose the connection before its response arrives, so Doqet sends the same body again.
Make your receiver idempotent
- Store each
X-Doqet-Delivery-IDyou process and skip any you have already seen. - Record the ID in the same transaction as your side effect, so a crash can't separate them.
- Respond with 2xx quickly and do slow work in the background. If your response headers don't arrive within 10 seconds, the attempt counts as a timeout and is retried.
- Don't rely on arrival order. Use
created_atwhen order matters.
Integration API
The integration API manages one workspace with an API key instead of a browser session, so scripts and agents can set up routing and read what happened. Send requests to /api/integration/<route> with Authorization: Bearer dqk_....
- Owners and administrators create and revoke keys under Integration in the console. A workspace can have up to 20, on every plan. A key is shown once.
- A
readkey sees the overview, sources, destinations, rules, events and their deliveries, the inbox, usage, billing and the audit log, and can preview rules. Webhook URLs show only their host, as inhttps://example.com/***. - A
writekey can also create, change and archive sources, destinations and rules, rotate source tokens, send events and test messages, and retry deliveries. - A key never has more access than the member who created it, and it stops working if they leave the workspace.
- Members, billing changes, retention settings and key management stay in the console.
curl "https://doqet.app/api/integration/sources" \
-H "Authorization: Bearer $DOQET_API_KEY"An API key is not a source token. A source token only sends events to its own source's ingest address, and an API key doesn't work there.
The full reference is plain Markdown for scripts and agents, and covers everything an integration needs. The Claude Code skill operates a workspace for you with the same API.