Guides
Receiving webhooks
HMAC verification, replay protection, idempotency, response expectations. Hand to a receiver implementer.
Implementing a Tidenda webhook receiver
This document is the receiver-side contract for a webhook registered in Tidenda. It is intentionally self-contained — every detail an implementation needs is on this page.
This file is strictly about the HTTP contract a third-party endpoint has to honour. The sender-side (registering webhooks, inspecting deliveries, replaying) is configured in the application's admin interface and described in Webhooks.
TL;DR
- You expose one HTTPS endpoint that accepts
POSTrequests with a JSON body. - Every request carries an
X-Void-Signature: sha256=<hex>header. You must verify it before trusting the body — drop the request otherwise. - The signed material is
${X-Void-Timestamp}.${raw-body}with HMAC-SHA256 over your shared secret. Reject requests where the timestamp is more than 5 minutes off your clock (replay window). - Respond 2xx to acknowledge within 10 seconds. Anything else triggers a retry. Defer slow work to a background job.
- The sender retries up to 11 times with backoff 30s, 1m, 2m, 5m,
10m, 20m, 30m, 60m, 6h, 12h (total budget ~19 hours). Dedupe on
X-Void-Event-Id— it's stable across all retries of the same event. - The full document content rides inside the body as a
base64-encoded JSON blob (
document.snapshotBytes). Decode → utf-8 →JSON.parseto get the article/planning/newswire. No second fetch needed; no signed URL that can expire. - If you re-publish the content downstream (e.g. CDN, search index),
the Ed25519
snapshotSignaturelets you carry Tidenda's signature forward so your consumers can verify content authenticity without trusting your hub.
A minimal Node.js handler that does the right thing:
import crypto from 'node:crypto'
import express from 'express'
const SECRET = process.env.VOID_WEBHOOK_SECRET // wh_xxxxxxxx_xxxxxx…
const app = express()
// We need the *raw* body bytes for signature verification. Don't let
// express parse JSON before we've checked the signature.
app.post('/void/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('X-Void-Signature') ?? ''
const timestamp = req.header('X-Void-Timestamp') ?? ''
const eventId = req.header('X-Void-Event-Id') ?? ''
if (!verify(SECRET, timestamp, req.body, signature)) {
return res.status(401).end() // bad sig or stale timestamp
}
// Dedupe: same X-Void-Event-Id seen before? Acknowledge and stop.
if (seen(eventId)) return res.status(200).end()
markSeen(eventId)
// Acknowledge fast, do the work async.
const event = JSON.parse(req.body.toString('utf8'))
void processInBackground(event)
res.status(200).end()
})
function verify(secret, timestamp, bodyBuf, header) {
if (!header.startsWith('sha256=')) return false
// Replay protection: 5 minutes of clock skew tolerance.
const ts = Number.parseInt(timestamp, 10)
if (!Number.isFinite(ts)) return false
if (Math.abs(Date.now() / 1000 - ts) > 300) return false
const expected = crypto.createHmac('sha256', secret)
.update(`${ts}.${bodyBuf.toString('utf8')}`)
.digest('hex')
const given = header.slice('sha256='.length)
if (expected.length !== given.length) return false
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))
}
The rest of this document is the precise spec behind each line of that
example, plus how to read the actual content from document.snapshotBytes.
1. HTTP request anatomy
Method and URL
- Method:
POST - URL: whatever you registered when creating the webhook in Tidenda. Tidenda never appends query parameters; the URL it calls is exactly the URL you provided.
- TLS: Tidenda enforces
https://for non-localhost URLs. Your receiver should terminate TLS itself or sit behind a reverse proxy that does.
Headers
Every request sets all of these:
| Header | Type | Meaning |
|---|---|---|
Content-Type |
constant | Always application/json. |
User-Agent |
constant | tidenda-webhook/1.0. Useful for log filtering. |
X-Void-Event-Id |
string (ULID-shaped, 26 chars) | The event identifier. Stable across retries of the same event. Use this as your dedup key. |
X-Void-Delivery-Id |
string (ULID-shaped, 26 chars) | This attempt's identifier. Unique per delivery attempt; differs across retries. Useful for support ("we sent delivery 01J…XYZ, did you receive it?"). |
X-Void-Event |
string | The event type (e.g. document.usable). Mirrors type in the body. |
X-Void-Tenant-Id |
UUID string | The Tidenda tenant that owns the document. |
X-Void-Webhook-Id |
UUID string | The webhook registration that matched this event. |
X-Void-Timestamp |
integer string (Unix seconds) | The moment this attempt was dispatched. Used in signature computation. Regenerated on each retry, so it differs across retries of the same event. |
X-Void-Signature |
string | sha256=<lowercase-hex-digest>. See §2. |
There is no API version header. See §7 on how the protocol evolves.
Body
Always a JSON object. The schema:
interface WebhookPayload {
/** Same as X-Void-Event-Id. Stable across retries. */
id: string
/** Event type. See §6 for the full enum. */
type: 'document.draft'
| 'document.usable'
| 'document.withheld'
| 'document.cancelled'
| `document.${string}` // custom workflow statuses
| 'webhook.test'
/** ISO 8601 datetime. Regenerated on each attempt, including retries. */
occurredAt: string
/** Same as X-Void-Tenant-Id. */
tenantId: string
/** Same as X-Void-Webhook-Id. */
webhookId: string
/** The document that transitioned. null for `webhook.test`. */
document: {
id: string
type: string // e.g. 'core/article'
version: number // non-negative int
status: string // the status it transitioned *to*
title: string | null // flat string convenience, may be null
/** Base64-encoded raw JSON bytes of the immutable snapshot.
* Decode → utf-8 → JSON.parse for the full document. See §3.
* null when the event has no snapshot (see below). */
snapshotBytes: string | null
/** Ed25519 signature over the decoded `snapshotBytes`. null
* when snapshotBytes is null. See §4. */
snapshotSignature: {
alg: 'ed25519'
keyId: string
sig: string // base64
} | null
} | null
/** Only present, and true, for `webhook.test`. */
test?: boolean
}
When snapshotBytes is null:
- The event is
webhook.test(no document state to snapshot). - The transition is to a status that doesn't produce a snapshot —
typically pre-publication:
draft,cancelled, or custom internal-only statuses. The webhook still fires so you can react to the transition, but there's no immutable content yet.
If snapshotBytes is present, snapshotSignature is present too.
They travel as a pair.
Size limit: Tidenda caps an embedded snapshot at 2 MB of raw JSON (before base64). If a document exceeds the cap, Tidenda refuses to send the event and surfaces a permanent-failure entry in its admin UI; you won't receive that delivery at all. This is by design — receivers shouldn't be left guessing whether a missing event is "you weren't supposed to know" or "the system silently dropped a 60-MB body".
2. Verifying the webhook signature
This is the only security guarantee that the request actually came from Tidenda. Do not parse the body and act on its contents before verifying.
Algorithm
expected = HMAC_SHA256(secret, `${X-Void-Timestamp}.${raw-request-body}`)
header = `sha256=${hex(expected)}`
secretis the value you copied from Tidenda's admin UI when you registered the webhook. Format:wh_<8-hex>_<43-base64url-chars>, total ~52 characters. Store it as the receiver's environment variable — never in source code, never in logs.- The body must be the raw bytes received on the wire, byte-for-byte.
If your framework parses JSON before handing the body to your handler,
re-serialising it will mangle whitespace and unicode escapes and the
signature will fail. In Express, use
express.raw({ type: 'application/json' })on the route. In Fastify, useaddContentTypeParser(..., { parseAs: 'buffer' }). In FastAPI, readawait request.body()before any pydantic parsing. - HMAC variant: SHA-256.
- Encoding: lowercase hex.
- Constant-time compare the digest. Don't use
===. Usecrypto.timingSafeEqual(Node),hmac.compare_digest(Python),crypto.subtle.timingSafeEqual(Web Crypto). String equality leaks bytes through timing.
Replay protection
The timestamp is part of the signed material specifically so you can reject stale requests. Implement a 5-minute tolerance window:
if (abs(now_seconds - X-Void-Timestamp) > 300) reject
What "fail verification" should look like
Return 401 Unauthorized with no body. Do not respond 200 to a
failed-signature request — that teaches an attacker their tampering
was undetectable.
Log enough to investigate (delivery ID, IP, reason: bad sig / stale timestamp / missing header) but never log the secret or the incoming signature header.
3. Reading the document content
When document.snapshotBytes is non-null, decode it to get the full
article / planning / newswire:
const bytes = Buffer.from(payload.document.snapshotBytes, 'base64')
const snapshot = JSON.parse(bytes.toString('utf8'))
// `snapshot` is now the full document (see §6 for per-type schemas).
Python:
import base64, json
bytes_ = base64.b64decode(payload['document']['snapshotBytes'])
snapshot = json.loads(bytes_.decode('utf-8'))
That's the entire fetch step. There is no snapshotUrl to GET. The
content is right there in the webhook body, immutable, signed.
4. Verifying the snapshot signature (Ed25519)
The webhook's HMAC (§2) proves "this HTTP request came from Tidenda".
The Ed25519 snapshotSignature proves "this content came from Tidenda
and was not modified by any intermediary". You need this second
layer if you re-publish the content — for example, a distribution hub
that converts the article to NewsML or ninjs and forwards to multiple
publishers. The publishers don't trust the hub; they trust Tidenda. The
Ed25519 signature is what they verify against.
If you don't re-distribute the content (you push it into your own database, search index, CDN, etc., and your downstream readers trust you), the HMAC is enough and you can skip this section.
What's signed
The exact byte sequence:
"void-snapshot-v1\0" || decoded_snapshotBytes
i.e. the string void-snapshot-v1 followed by a NUL byte followed by
the decoded raw bytes of the snapshot. The Ed25519 signature is over
that concatenated buffer.
Important: verify against the decoded bytes, not against a
re-serialised parsed object. JSON doesn't have a canonical
serialisation — JSON.stringify(JSON.parse(bytes)) won't reproduce
bytes, and the signature won't match. Always work from
Buffer.from(snapshotBytes, 'base64').
Where to get the public key
Tidenda publishes its current and historical public keys at:
GET <Tidenda-public-api>/api/keys
(unauthenticated). The response:
{
"active": "void-2026-05",
"keys": [
{
"keyId": "void-2026-05",
"algorithm": "ed25519",
"publicKey": "<base64 SPKI DER>",
"validFrom": "2026-05-29T...",
"validUntil": "2027-05-29T..."
}
]
}
The keyId field in the snapshot's signature tells you which key
signed it; look up that key in the response. Older keys remain in
keys (with validUntil set) so historical snapshots stay verifiable
after rotation. Cache the response for an hour or so — the keys
change rarely.
Verifying
import { createPublicKey, verify } from 'node:crypto'
const DOMAIN = Buffer.from('void-snapshot-v1\0', 'utf8')
function verifySnapshot(snapshotBytesB64, signature, keys) {
const bytes = Buffer.from(snapshotBytesB64, 'base64')
const key = keys.find(k => k.keyId === signature.keyId)
if (!key) throw new Error(`Unknown keyId: ${signature.keyId}`)
const publicKey = createPublicKey({
key: Buffer.from(key.publicKey, 'base64'),
format: 'der',
type: 'spki'
})
const signed = Buffer.concat([DOMAIN, bytes])
const ok = verify(null, signed, publicKey, Buffer.from(signature.sig, 'base64'))
if (!ok) throw new Error('Snapshot signature does not verify')
return JSON.parse(bytes.toString('utf8'))
}
Python (with cryptography):
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives import serialization
DOMAIN = b'void-snapshot-v1\x00'
def verify_snapshot(snapshot_b64, signature, keys):
raw = base64.b64decode(snapshot_b64)
key = next(k for k in keys if k['keyId'] == signature['keyId'])
public_key = serialization.load_der_public_key(
base64.b64decode(key['publicKey'])
)
assert isinstance(public_key, Ed25519PublicKey)
public_key.verify(base64.b64decode(signature['sig']), DOMAIN + raw)
return json.loads(raw.decode('utf-8'))
If the signature doesn't verify, drop the event and alert. Something is very wrong — either the content was modified in transit (shouldn't happen since the HMAC covers the body), a key was mishandled, or someone is trying to inject fake content. None of those should produce a downstream republish.
5. Idempotency: handling duplicate deliveries
Tidenda retries failed deliveries (§6). It also lets operators manually
replay historical deliveries from the admin UI. Both arrive at your
endpoint with the same X-Void-Event-Id.
You will receive duplicates. Plan for it.
The dedup key
X-Void-Event-Id is a ULID. Stable across:
- All automatic retries of the same event
- Manual operator replays of the same event
- Even partial-failure scenarios (your endpoint 200'd but the response never made it back to Tidenda)
Recommended pattern
Database-backed (preferred for production): keep a table
processed_events(event_id PRIMARY KEY, processed_at). Insert withON CONFLICT (event_id) DO NOTHING. If the insert affected 0 rows, you've seen this event — short-circuit and return 200.Cache-backed (acceptable for ephemeral side effects): put the event_id into Redis / memcached with a TTL of at least ~24 hours (longer than the longest the sender will keep retrying, currently ~19h end-to-end).
Replays are not distinguishable from retries
A replay carries the same X-Void-Event-Id as the original delivery.
No header or payload field marks it as a replay. From your
perspective, replays are retries. Your dedup logic handles both the
same way.
If your workflow requires "re-process this event because the operator said so" (e.g. re-publish to a CDN), wire that up out-of-band, not through the webhook channel.
6. Response expectations + retry behaviour
What you respond
| Status | Tidenda interprets as | Tidenda reaction |
|---|---|---|
| 2xx (200-299) | Success | Delivery is final. No retry. |
| 3xx | Failure | Counts as one failed attempt, retries per schedule. Tidenda does not follow redirects. |
| 4xx | Failure | Counts as one failed attempt. (Including 400, 401, 404, 410.) |
| 5xx | Failure | Counts as one failed attempt. |
| Network error / timeout | Failure | Counts as one failed attempt. |
Use 2xx liberally. A 200 with an empty body is fine. A 202 with "queued for processing" is also fine.
Timeout
Tidenda aborts the request at 10 seconds. That's the budget for TLS handshake + request + headers + body. Aim well under it — say, 2 seconds.
Don't do real work in the handler. Verify the signature, dedupe, enqueue to a background job, return 200.
Retry schedule
| Attempt | Delay since last failure |
|---|---|
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 2 minutes |
| 5 | 5 minutes |
| 6 | 10 minutes |
| 7 | 20 minutes |
| 8 | 30 minutes |
| 9 | 60 minutes |
| 10 | 6 hours |
| 11 | 12 hours |
Total wall-clock budget across the 11 attempts: ~19 hours. The pre-1h portion is tight so quick fixes (CSRF carve-out, deploy restart, config push) usually catch a retry in the same hour; after that the gaps widen so a long receiver outage doesn't burn the budget in the first half-hour.
After 11 failed attempts, the delivery is marked failed permanently in Tidenda's admin UI for an operator to replay or investigate.
Things that change across retries
Same in every attempt of the same event:
- Body's
idfield X-Void-Event-IdheaderX-Void-Event,X-Void-Tenant-Id,X-Void-Webhook-IdsnapshotBytesandsnapshotSignature(the snapshot is immutable)
Different on every retry:
X-Void-Delivery-Id(unique per attempt)X-Void-Timestamp(re-stamped on dispatch)X-Void-Signature(recomputed because timestamp changed)occurredAtin the body (re-stamped on dispatch)
Verify the signature against the headers in the request you just received, not against any cached value from a previous attempt.
7. Versioning and evolution
There is no version field in the payload and no X-Void-Api-Version
header. The contract evolves under two rules:
- Additive changes are non-breaking. New fields may be added to the body or the snapshot. New event types may be added under the existing prefixes.
- Breaking changes get a new event type prefix. If Tidenda ever
needs to change the body shape incompatibly, the new shape ships
under a new prefix (e.g.
document.v2.usable); the old prefix continues to fire the old shape.
To stay compatible:
- Treat unknown fields as forwards-compatible. Don't throw on fields you don't recognise; ignore them.
- Pattern-match on type prefix when reasonable.
if (type.startsWith('document.'))beats hand-listing the four built-in statuses, because the tenant may add custom statuses you haven't seen before.
8. Snapshot content: what's actually in there
After decoding snapshotBytes, you have a JSON object describing the
document. The shape depends on document.type. The three current
types share a common envelope:
interface SnapshotEnvelope {
id: string // matches document.id
type: string // 'core/article' | 'core/planning-item' | 'core/newswire'
title: RichTextNode[] // rich-text, NOT a flat string — see below
meta: Record<string, unknown>
links: LinkObject[]
content: Record<string, unknown> // shape depends on type
// (history is stripped from snapshots)
}
Titles and bodies are rich-text JSON, not plain strings
title, and the rich-text body fields in content, are a
nested-node JSON tree, not a flat string. Each RichTextNode is
either a { text, ...marks } leaf (with optional boolean marks like
bold, italic, underline) or a { type, children, ...attrs }
block. Blocks nest.
A simple title:
[
{ "type": "paragraph", "children": [{ "text": "Article headline" }] }
]
A body with formatting:
[
{
"type": "paragraph",
"children": [
{ "text": "Plain text in a paragraph " },
{ "text": "bold bit", "bold": true },
{ "text": "." }
]
},
{
"type": "heading",
"level": 2,
"children": [{ "text": "Subheading" }]
},
{
"type": "ul",
"children": [
{ "type": "li", "children": [{ "text": "First item" }] },
{ "type": "li", "children": [{ "text": "Second item" }] }
]
}
]
If you need a flat string for indexing or notifications, the convenient
document.title field at the top of the webhook payload is
already that — Tidenda extracts it for you. For the body, walk the
node tree:
function flatten(nodes) {
if (!Array.isArray(nodes)) return ''
return nodes.map(n =>
typeof n.text === 'string' ? n.text :
Array.isArray(n.children) ? flatten(n.children) :
''
).join('')
}
For HTML rendering, walk the tree and emit <p>, <h2>, <ul>,
etc. from each block's type; the leaf text values inherit the
current tag. Boolean marks on a leaf map to inline elements
(bold → <strong>, italic → <em>, and so on).
meta fields you'll see
The meta object varies by document type but the common fields:
| Field | Type | Meaning |
|---|---|---|
priority |
int 1-6 | Editorial priority. 1 = highest. |
visibility |
'internal' | 'public' |
Audience scope. |
status |
string | Workflow status the document is in. Same as document.status in the webhook. |
version |
int | Document version. Same as document.version. |
publishedVersion |
int | Version number if the document is currently published. Absent if never published. |
lang |
string | Optional ISO language code. |
time |
array | Date/time annotations. Type depends on document. |
links
An array of references to other documents (and external URLs). Common shape:
{
type?: string // e.g. 'core/planning-item', 'core/planning-assignment'
rel?: string // relationship role (e.g. 'parent')
uri?: string // a document ID in this tenant, OR an external URL
title?: string // display title for the link
}
When type starts with core/, the uri is a document ID in the
same tenant, not an external URL. If you need to fetch that linked
document's content too, you'd need the same webhook to fire for that
document type (or some other integration path — direct fetch isn't
exposed yet).
9. Per-document-type schemas
core/article
{
id: string
type: 'core/article'
title: RichTextNode[]
meta: {
priority: 1 | 2 | 3 | 4 | 5 | 6
visibility?: 'internal' | 'public'
time?: Array<
| { type: 'target' | 'slot' | 'start' | 'end'
time: string // ISO 8601 datetime
timeZone: string }
| { type: 'startdate' | 'enddate'
time: string // ISO 8601 date (YYYY-MM-DD)
timeZone: string }
>
lang?: string
status?: string
version?: number
publishedVersion?: number
// …other tenant-specific fields
}
links: LinkObject[] // includes at least one core/planning-item
// and one core/planning-assignment
content: {
text: RichTextNode[] // the article body
}
}
core/planning-item
{
id: string
type: 'core/planning-item'
title: RichTextNode[]
meta: {
priority: 1-6
visibility?: 'internal' | 'public'
time?: Array<{
type: 'startdate' | 'enddate'
time: string // YYYY-MM-DD (date, not datetime)
timeZone: string
}>
// …
}
links: LinkObject[]
content: {
slugline: string // short slug
description: RichTextNode[] // planning description
assignments: Array<{
id: string
type: 'core/planning-assignment'
title: RichTextNode[]
meta: {
type: 'text' | 'flash' | 'image' | 'graphic' | 'video' | 'audio'
// …
}
content: {
slugline: string
description: RichTextNode[]
}
links: LinkObject[]
}>
}
}
core/newswire
{
id: string
type: 'core/newswire'
title: RichTextNode[]
meta: {
origin: 'wire' | 'pressrelease' | 'media'
source: string // source name/code
priority: 1-6
visibility?: 'internal' | 'public'
time?: Array<TimeObject>
// …
}
links: LinkObject[]
content: {
text: RichTextNode[] // wire body
}
}
10. Event types
| Type | When it fires | snapshotBytes |
|---|---|---|
document.usable |
Transitioned to usable (published) |
populated |
document.withheld |
Transitioned to withheld (scheduled / embargoed) |
populated |
document.draft |
Transitioned to draft |
null (drafts aren't snapshotted) |
document.cancelled |
Transitioned to cancelled (unpublished) |
null |
document.<custom> |
Transitioned to a tenant-defined workflow status. <custom> matches [a-z0-9_-]+. |
null (custom statuses don't produce snapshots by default) |
webhook.test |
Operator clicked "Send test" in the admin UI. | null, and test: true is also set on the body |
Things that don't trigger deliveries:
- Document deletions
- Saves that don't change the workflow status
- Changes to the workflow definition itself
11. Security checklist
- HMAC signature verified before any business logic touches the body.
- Constant-time comparison (
timingSafeEqual/hmac.compare_digest). - 5-minute timestamp tolerance window.
- Raw body used for verification (not re-serialised JSON).
- Secret stored in an env var / secret manager, never in source.
- No PII / payload contents logged from failed-signature requests.
- HTTPS terminated; HTTP rejected.
- Dedup by
X-Void-Event-Idis in place before side effects. - Slow work deferred to a background queue; handler returns < 2s.
- (If re-distributing content) Ed25519
snapshotSignatureverified against the public key fromGET /api/keys, against the decoded bytes (not against re-serialised JSON).
12. Testing your receiver
Tidenda provides a "Send test" button in the admin UI for each
webhook. It fires a webhook.test event with document: null,
snapshotBytes: null, and test: true. Useful for verifying
signature / dedup / return-2xx without needing to push a real document
through your workflow.
Synthesise the same test event in your own test suite:
const body = JSON.stringify({
id: '01J0TESTEVENT0000000000000',
type: 'webhook.test',
occurredAt: new Date().toISOString(),
tenantId: '00000000-0000-0000-0000-000000000000',
webhookId: '00000000-0000-0000-0000-000000000000',
document: null,
test: true
})
const ts = Math.floor(Date.now() / 1000)
const sig = crypto.createHmac('sha256', SECRET)
.update(`${ts}.${body}`)
.digest('hex')
// POST with X-Void-Timestamp=ts, X-Void-Signature=sha256=${sig}
Tests worth writing:
- Valid signature + fresh timestamp → 200
- Tampered body (one character changed) → 401
- Stale timestamp (~6 minutes old) → 401
- Missing signature header → 401
- Same event_id sent twice → both 200, but only one downstream side effect
- Receiver throws inside handler → returns 500, Tidenda retries
- Handler takes 11 seconds → Tidenda times out, retries
- (If verifying snapshot sig) Tampered
snapshotBytes(re-base64'd with one char changed) → snapshot signature does not verify, alert fires, downstream republish does NOT happen
13. Quick reference
Method: POST
Body: application/json, schema in §1
Headers: X-Void-Event-Id (stable, dedup key)
X-Void-Delivery-Id (unique per attempt)
X-Void-Event (event type)
X-Void-Tenant-Id
X-Void-Webhook-Id
X-Void-Timestamp (unix seconds, in signed material)
X-Void-Signature (sha256=<hex>)
User-Agent (tidenda-webhook/1.0)
Signature:
sha256(secret, `${timestamp}.${raw-body}`) → hex
Verify in constant time; reject stale timestamps (>300s skew).
Snapshot:
document.snapshotBytes (base64, decode → utf-8 → JSON.parse)
document.snapshotSignature Ed25519 over "void-snapshot-v1\0" || bytes
Public keys at GET /api/keys (cache for ~1h).
Acknowledge with 2xx within 10s. Dedupe on X-Void-Event-Id.
Retries: 11 attempts at 30s / 1m / 2m / 5m / 10m / 20m / 30m / 60m / 6h / 12h.
That's the whole contract.