Every REST API has three shape decisions that determine whether the client code will be pleasant or miserable: the envelope, the pagination model, and the error format. This page is the reference for all three, with a JSON Schema fragment you can paste into your OpenAPI generator.
Envelope is { data, error, meta } on every response. Pagination is cursor-based with Link headers (RFC 5988). Timestamps are ISO 8601 with explicit UTC. Errors always carry a requestId you can cite in support tickets.
Response envelope
Every JSON response — success or error — has the same top-level shape:
{
"data": <object | array | null>,
"error": <object | null>,
"meta": <object>
}On success, data is populated and error is null. On failure, the reverse. meta is always present and always contains at least a requestId.
Success example
{
"data": {
"id": "t_01H9XABC4D5E6F7G8H",
"status": "complete",
"summary": { "inboxCount": 18, "spamCount": 3, "missingCount": 1, "total": 22 }
},
"error": null,
"meta": {
"requestId": "req_01H9X7KEYABCDEF",
"creditsRemaining": 248,
"version": "2026-07-01"
}
}Error example
{
"data": null,
"error": {
"code": "validation_error",
"message": "Field 'from' must be a valid email address",
"field": "from",
"docs": "https://check.live-direct-marketing.online/docs/errors#validation_error"
},
"meta": {
"requestId": "req_01H9X7KEYBADBEEF",
"version": "2026-07-01"
}
}Status code map
200 OK— successful read.202 Accepted— test created, running asynchronously. Follow theLocationheader or poll the returnedid.400 Bad Request— malformed JSON or missing required field. Do not retry.401 Unauthorized— missing or invalid Bearer key. Do not retry.403 Forbidden— authenticated but not allowed (for example, reading another account's test). Do not retry.404 Not Found— test ID does not exist or has been purged past retention.409 Conflict— idempotency collision with a different payload body.422 Unprocessable Entity— request is well-formed but rejected by domain logic (e.g. sender domain blocked).429 Too Many Requests— rate limit hit. ReadRetry-After.5xx— upstream or internal failure. Retry with exponential backoff.
Pagination — cursor-based
Every list endpoint uses forward-only cursor pagination. You pass ?limit=N&cursor=..., and receive meta.nextCursor (or null) plus a Link header with rel="next".
GET /api/tests?limit=50
200 OK
Link: <https://check.live-direct-marketing.online/api/tests?limit=50&cursor=eyJpZCI6InRfMDEifQ==>; rel="next"
{
"data": [ /* 50 test objects */ ],
"error": null,
"meta": {
"nextCursor": "eyJpZCI6InRfMDEifQ==",
"hasMore": true,
"requestId": "req_..."
}
}Why cursor, not page?
Page-based pagination breaks when items are inserted or deleted mid-walk — you can either miss items or see duplicates. Cursor pagination is stable under concurrent writes and is the model every serious REST API has converged on over the last decade.
The cursor is opaque — do not try to base64-decode it or sort by it. Treat it as a "continue reading here" token and nothing more.
Test object schema
{
"id": "t_01H9XABC4D5E6F7G8H",
"status": "complete",
"createdAt": "2026-07-03T11:02:14Z",
"completedAt": "2026-07-03T11:03:42Z",
"from": "hello@news.yourbrand.com",
"subject": "Weekly digest #42",
"summary": {
"inboxCount": 18,
"spamCount": 3,
"missingCount": 1,
"total": 22
},
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },
"spamAssassinScore": 1.2,
"rspamdScore": 0.8,
"providers": [ /* per-seed detail */ ],
"dnsbl": [ /* blacklist check results */ ],
"screenshots": [ /* screenshot URLs */ ],
"metadata": { /* opaque tenant metadata */ }
}Provider result sub-object
{
"provider": "gmail",
"folder": "inbox",
"folderRaw": "INBOX",
"tookMs": 4120,
"headersUrl": "https://check.live-direct-marketing.online/r/t_.../headers.txt",
"screenshotUrl": "https://check.live-direct-marketing.online/r/t_.../gmail.png"
}folder is normalised to a small enum: inbox, spam, promotions, updates, social, missing, other. folderRaw is the provider-native label for debugging.
SPF / DKIM / DMARC sub-object
{
"spf": {
"result": "pass",
"record": "v=spf1 include:_spf.google.com include:sendgrid.net ~all",
"domain": "news.yourbrand.com"
},
"dkim": {
"result": "pass",
"selector": "s1",
"domain": "news.yourbrand.com",
"keySize": 2048
},
"dmarc": {
"result": "pass",
"policy": "quarantine",
"alignment": { "spf": "pass", "dkim": "pass" }
}
}DNSBL sub-object
[
{ "list": "zen.spamhaus.org", "listed": false },
{ "list": "bl.spamcop.net", "listed": false },
{ "list": "b.barracudacentral.org", "listed": false },
{ "list": "dnsbl.sorbs.net", "listed": true, "reason": "dynamic IP range" }
]Screenshot URL lifecycle
Screenshot URLs are signed and time-limited. They survive as long as the test is within its retention window (30 days on free, 12 months on Starter, 24 on Growth), then return 404. The URL does not change — there is no rotation — so you can bookmark or embed it as long as the test is in retention.
Do not hotlink screenshots into tenant UI long-term. Download them and rehost if you need longer retention than your plan provides.
Timestamps — ISO 8601, always UTC
Every timestamp the API returns is ISO 8601, in UTC, with a trailing Z. Example: 2026-07-03T11:02:14Z. No other format is ever returned; no timezone offsets other than Z; no epoch seconds; no microseconds.
Render in the user's local timezone on your frontend using whatever library you already have. Never assume a server-side format change.
JSON Schema fragment
Paste this into your OpenAPI generator. It covers the envelope and the test object with enum-validated fields.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://check.live-direct-marketing.online/schema/test.json",
"type": "object",
"required": ["data", "error", "meta"],
"properties": {
"data": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"required": ["id", "status", "createdAt"],
"properties": {
"id": { "type": "string", "pattern": "^t_[A-Z0-9]{26}$" },
"status": { "enum": ["pending", "running", "complete", "cancelled", "failed"] },
"createdAt": { "type": "string", "format": "date-time" },
"completedAt":{ "type": "string", "format": "date-time" },
"from": { "type": "string", "format": "email" },
"subject": { "type": "string", "maxLength": 998 },
"summary": {
"type": "object",
"required": ["inboxCount", "spamCount", "missingCount", "total"],
"properties": {
"inboxCount": { "type": "integer", "minimum": 0 },
"spamCount": { "type": "integer", "minimum": 0 },
"missingCount": { "type": "integer", "minimum": 0 },
"total": { "type": "integer", "minimum": 0 }
}
},
"auth": {
"type": "object",
"properties": {
"spf": { "enum": ["pass", "fail", "neutral", "softfail", "none"] },
"dkim": { "enum": ["pass", "fail", "none"] },
"dmarc": { "enum": ["pass", "fail", "none"] }
}
}
}
}
]
},
"error": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"required": ["code", "message"],
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"field": { "type": "string" },
"docs": { "type": "string", "format": "uri" }
}
}
]
},
"meta": {
"type": "object",
"required": ["requestId"],
"properties": {
"requestId": { "type": "string" },
"creditsRemaining": { "type": "integer" },
"nextCursor": { "type": "string" },
"hasMore": { "type": "boolean" },
"version": { "type": "string" }
}
}
}
}The meta.version field echoes the API version your request was handled under. Pin to a version by sending X-API-Version: 2026-07-01. Without the header you get the latest stable version, which can introduce additive changes (new fields) but never breaking ones.