Skip to main content

Compliance

The compliance record is an entry for everything that happened to a workspace's posts once they left it: every post handed over, every update, delete and resend, every approval decision, every delivery's outcome, every report of where a post stands and every change to a channel's approval. Each entry is written once and never changed, sealed into the workspace's chain, signed by Reelwire, time-stamped once a day by an outside authority and kept ten years, whatever happens to the clip, the post or the channel since. What it is for, and how a person uses it, is on the Compliance screen's page.

The record is part of Enterprise, and records from the moment a workspace is on it. A workspace that leaves Enterprise records nothing new and keeps reading and exporting what it has for 90 days, then it is deleted. On any other plan reading it is a 403 with a sentence saying so. Changing the approval rules and adding auditors is Enterprise's alone, also a 403 during those 90 days; removing an auditor works on any plan.

MethodPathPermission
GET/v1/compliancecompliance:read
GET/v1/compliance/recordscompliance:read
GET/v1/compliance/records/{id}compliance:read
GET/v1/compliance/exportcompliance:read
GET/v1/compliance/auditcompliance:read
PUT/v1/compliance/approval-rules/{channelId}signed-in person: Approvals
GET/v1/compliance/auditorssigned-in person
POST/v1/compliance/auditorssigned-in person: Administration
POST/v1/compliance/auditors/{userId}/resendsigned-in person: Administration
POST/v1/compliance/auditors/{userId}/deletesigned-in person: Administration

Who reads it. A key holding the Compliance permission, which is read only: a machine may read the record, check a file against it and fetch the export for an archive of its own. A signed-in person holding Approvals or Administration (the owner holds both), and an auditor. The approval rules and the auditors answer a dashboard session and never a key, because one loosens a control and the other lets somebody in: a key gets the "Sign in to do this" refusal described under Authentication.

The overview​

curl http://localhost:4000/v1/compliance \
-H "Authorization: Bearer rw_your_key_here"
{
"on": true,
"lapsed": null,
"retentionYears": 10,
"graceDays": 90,
"entries": 1284,
"sealed": 1283,
"waiting": 1,
"firstEntryAt": "2026-10-14T09:12:40.118Z",
"chain": { "seq": 1283, "headHash": "4f0c9a...e71d" },
"anchor": {
"day": "2026-10-15",
"seq": 1270,
"state": "STAMPED",
"genTime": "2026-10-15T00:01:12.000Z",
"tsaUrl": "http://timestamp.digicert.com"
},
"timeStampAuthority": "http://timestamp.digicert.com",
"keys": [
{ "id": "01M3B4Z9Q8R0J5V2C6N1T7K4WD", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...", "createdAt": "2026-10-14T09:12:41.002Z", "retired": false }
],
"kinds": [
{ "id": "post.publish", "label": "Handed over" },
{ "id": "post.update", "label": "Update handed over" }
]
}

Shortened: kinds lists all eight, and hashes are 64 hexadecimal characters.

  • on is whether the workspace records now. lapsed is null, or, for a workspace that left Enterprise, since (when) and deletesOn (when what it recorded is deleted).
  • entries counts them, sealed those that have their number and signature, and waiting those sealed within the next seconds.
  • chain is the newest entry's number and hash.
  • anchor is the newest daily time stamp that covers this workspace, up to which entry (seq), and its state: PENDING while the authority is asked again, every ten minutes or so, STAMPED with the moment it signed (genTime), or FAILED once it was asked 48 times without an answer.
  • keys are the public halves of the keys Reelwire signs entries with, as PEM.

Entries​

curl "http://localhost:4000/v1/compliance/records?kind=post.publish&perPage=1" \
-H "Authorization: Bearer rw_your_key_here"
{
"records": [
{
"id": "01M3B5C2D1E0F9G8H7J6K5M4N3",
"kind": "post.publish",
"label": "Handed over",
"occurredAt": "2026-10-15T08:30:02.411Z",
"summary": "Orchid Invest DE: Der DAX schloss 412 Punkte hoeher.",
"tone": "good",
"channels": ["Orchid Invest DE"],
"channelIds": ["01M1VFRWE7EWSY5D7QDQ0KNK1S"],
"postKey": "01M3B5BZQ1X0W9V8T7S6R5Q4P3",
"postIds": ["01M3B5C0A9B8C7D6E5F4G3H2J1"],
"videoSha256": ["9b2e1f...c04a"],
"seq": 1283,
"sealed": true,
"entryHash": "4f0c9a...e71d",
"frames": 3,
"stills": "DONE"
}
],
"total": 412,
"all": 1284,
"page": 1,
"perPage": 1,
"facets": { "kind": ["post.publish", "post.update", "..."], "channelId": ["01M1VFRWE7EWSY5D7QDQ0KNK1S"] },
"labels": { "kind": { "post.publish": "Handed over" }, "channelId": { "01M1VFRWE7EWSY5D7QDQ0KNK1S": "Orchid Invest DE" } }
}

It pages, searches and orders like every log (see Lists and paging), newest first unless ascending=true:

ParameterWhat it does
since, untilOnly entries that happened in this window, ISO 8601 instants.
kindOne kind or several, comma separated (below).
channelIdOne channel or several, comma separated.
sha256Only the entries naming this video file, by the 64 hexadecimal characters of its SHA-256. Anything else is a 400. See Checking a video file.
qA post key, a post id, an entry's hash, or words of a caption or a channel's name.
page, perPageperPage is 1 to 200, 25 by default.

summary is the entry in one line, read from the entry alone, and tone how it reads at a glance (good, bad, waiting or plain). sealed is false for the few seconds before an entry has its number (seq) and hash. stills says where its frames stand: NONE for an entry without a video of its own, PENDING until they are taken, DONE, or MISSING where the clip had left storage first.

kindLabelWritten when
post.publishHanded overA post went out on one channel. One entry per channel of a hand-over.
post.updateUpdate handed overA change to a post that went out was handed over.
post.deleteDelete handed overA request to take a post down was handed over.
post.resendSent againA resend by email, by webhook or to syndication participants, a repost through the platform connector, or a retry of a post that failed.
approval.decisionApproval decisionSomebody approved or rejected a post or an update, or gave the first of two approvals.
delivery.outcomeDelivery outcomeAn email, a webhook call or a platform connector post was delivered, or failed after every attempt.
post.reportReportedA post was said to be published (with its link), skipped, not posted or deleted.
channel.approvalApproval rule changedA channel's approval was switched, or its number of approvers changed.

One entry​

GET /v1/compliance/records/{id} answers one entry whole: what was recorded, its seal, the daily time stamp that covers it and its frames.

{
"record": {
"id": "01M3B5C2D1E0F9G8H7J6K5M4N3",
"kind": "post.publish",
"label": "Handed over",
"occurredAt": "2026-10-15T08:30:02.411Z",
"summary": "Orchid Invest DE: Der DAX schloss 412 Punkte hoeher.",
"body": {
"v": 1,
"kind": "post.publish",
"occurredAt": "2026-10-15T08:30:02.411Z",
"workspace": { "id": "01M1VFRWE3A35H892VAAEKKDA6", "name": "Orchid Invest" },
"handover": { "id": "01M3B5C1...", "postKey": "01M3B5BZQ1X0W9V8T7S6R5Q4P3", "ways": ["EMAIL", "WEBHOOK"] },
"post": { "id": "01M3B5C0A9B8C7D6E5F4G3H2J1", "postGroupId": "01M3B5BZW..." },
"channel": { "id": "01M1VFRWE7EWSY5D7QDQ0KNK1S", "name": "Orchid Invest DE", "platform": "instagram", "surface": "instagram-reels", "ratio": "9x16", "locale": "de" },
"brand": "Orchid Invest",
"template": { "id": "tpl_dd7e6b264fb8", "variant": "Corporate glass", "version": "1.0.0", "build": "a41c...9e02" },
"source": { "kind": "queue", "queueName": "Market close", "title": "DAX close" },
"author": { "kind": "person", "id": "anna@orchid.example", "name": "Anna Berg" },
"ai": null,
"approval": {
"required": true,
"needed": 1,
"decisions": [{ "by": { "kind": "person", "id": "lena@orchid.example", "name": "Lena Kraus" }, "at": "2026-10-15T08:29:51.004Z", "note": "Figures checked." }]
},
"video": { "assetId": "01M3B5BZX...", "kind": "video", "ratio": "9x16", "mimeType": "video/mp4", "width": 1080, "height": 1920, "durationMs": 12000, "byteSize": 3481202, "sha256": "9b2e1f...c04a" },
"text": { "caption": "Der DAX schloss 412 Punkte hoeher.", "hashtags": ["dax", "boerse"], "disclaimer": "Keine Anlageberatung." }
},
"bodyHash": "c3d1...77aa",
"seal": {
"seq": 1283,
"prevHash": "8e91...02bd",
"entryHash": "4f0c9a...e71d",
"signature": "3vQ9...Bw==",
"keyId": "01M3B4Z9Q8R0J5V2C6N1T7K4WD",
"sealedAt": "2026-10-15T08:30:03.120Z"
},
"anchor": null,
"stills": "DONE",
"frames": [
{ "id": "01M3B5D...", "atMs": 1200, "width": 480, "height": 854, "url": "http://localhost:4000/v1/assets/01M3B5D.../download?exp=...&sig=...", "expiresAt": "2026-10-15T09:31:00.000Z" }
]
}
}

Shortened: the three frames are taken at a tenth, the middle and nine tenths of the video, and their links work for an hour. anchor is null until the next daily time stamp covers the entry, then its day, state, genTime, tsaUrl and root. An id that is not one of this workspace's entries is a 404.

What body carries depends on its kind, and every body says its v, kind and occurredAt:

  • A hand-over (post.publish, post.resend): the workspace, the hand-over with its ways (EMAIL, WEBHOOK, CONNECTOR, SYNDICATION) and, on a resend, again, saying which; the post (with permalink where one was known), the channel, the brand, the template with the fingerprint of its build, the source, the author (a person, an API key, or feed for a ready-made feed), ai (what the feed server's AI steps did to a ready-made feed's content, as it said when it sent it), the approval with every decision, the video and cover with their SHA-256, the text, every length of the caption it was handed over in (captions) and the posting settings. A resend names who asked for it (requestedBy).
  • An update: the same channel and post, requestedBy, the approval the change went through, and the change itself: its text, its captions and, where it replaced the video, the new video.
  • A delete: the channel and post, and requestedBy.
  • An approval decision: approval with about (post or update), decision (approved, rejected or first-approval), needed, by, note, requestedBy (the author) and, for an update, the change approved.
  • A delivery's outcome: delivery with its way, outcome (delivered or failed), attempts, the webhook's or platform's target, the last status and a note.
  • A report: report with the post's state (PUBLISHED, SKIPPED, FAILED or RECALLED), by, and the url, platformPostId or reason given.
  • An approval rule changed: the channel, by, and from and to, each its approval and approvers.

People are named by value as they were at that moment (kind, id as the sign-in address or the key's id, and name), so a colleague removed or a key revoked since still names who did it. Text that came from outside the workspace, a receiver's error or a reason somebody typed into a report, is recorded as it arrived.

Checking a video file​

Which post is this file? Work out its SHA-256 where it is, and ask:

sha256sum clip.mp4          # macOS: shasum -a 256 clip.mp4
curl "http://localhost:4000/v1/compliance/records?sha256=9b2e1f...c04a" \
-H "Authorization: Bearer rw_your_key_here"

Every entry that names that file comes back. A match proves it is the file handed over, to the byte. A copy downloaded from a platform never matches, because platforms re-encode what they publish: the fingerprint is of the file Reelwire handed over, the one your receiver or your post kit got, which is the one to keep.

The export​

GET /v1/compliance/export answers a ZIP for a period and, where asked, some channels: what an auditor is handed. It is what the screen's Download export for an auditor downloads.

curl -o compliance.zip \
"http://localhost:4000/v1/compliance/export?since=2026-09-01T00:00:00Z&until=2026-09-30T23:59:59Z&channelId=01M1VFRWE7EWSY5D7QDQ0KNK1S" \
-H "Authorization: Bearer rw_your_key_here"
ParameterWhat it does
since, untilThe period, ISO 8601 instants. until defaults to now and since to 30 days before it. A period that starts after it ends is a 400.
channelIdSome channels only, comma separated or repeated. Absent is every channel.

It answers application/zip, its file name the workspace's and the period's. Only sealed entries are in it; one written in the last seconds is in the next. One export holds at most 3,000 entries: a period holding more is a 409 that says how many it holds, so ask for a shorter period or fewer channels and fetch the rest separately. Every export is written to the audit log (compliance.exported), with the period and channels.

FileWhat it is
report.pdfThe record laid out for a person, post by post, with three frames of each video. Its first page says what Reelwire found when it checked the export before writing it. It describes; records.json proves.
records.jsonentries, each with its body, bodyHash, seq, prevHash, entryHash, signature and keyId; chain, every link from the period's first entry to the newest one a daily time stamp covers; anchors, each daily time stamp with this workspace's proof; and keys, the public keys that signed.
anchors/Each daily time stamp (RFC 3161), as the authority answered it, one .tsr file per day.
stills/The frames the report shows, named by entry and position.
verify.mjsEvery check below, written out. Node.js 18 or newer, nothing to install.
README.txtWhat all of it is, and how to check it.

Verifying an export​

Nothing in it asks Reelwire. From the folder it was unpacked into:

node verify.mjs                     # every entry, the chain and the time stamps
node verify.mjs --clips ./videos # also: which entry each video in ./videos is

It prints one line per finding, ends with "Everything checked holds." or how many checks failed, and exits with 1 when anything failed. What it checks, for anybody writing their own:

  1. Each entry's content. bodyHash is the SHA-256, in hexadecimal, of body as canonical JSON: keys sorted at every level, nothing undefined, no spaces.

  2. Each entry's seal. entryHash is the SHA-256 of the canonical JSON of an object holding the workspace's id (tenantId, workspace.id in records.json), seq, prevHash and bodyHash. signature is Reelwire's Ed25519 signature, in base64, over the 32 bytes of entryHash, checked with the key in keys that keyId names.

  3. The chain. Numbers follow on without a gap, and each link's prevHash is the entryHash of the one before it. The first entry a workspace ever recorded links to 64 zeros. An entry changed, removed or slipped in afterwards breaks every link after it.

  4. The daily time stamps. Once a UTC day, the newest entry of every workspace that recorded something becomes a leaf of one Merkle tree, and an outside time-stamping authority signs its root. A leaf is the SHA-256 of a 0 byte followed by tenantId, seq and headHash joined by line feeds; a node is the SHA-256 of a 1 byte followed by the two hashes' 32 bytes each. Each anchor's proof.path lists the sibling hashes from the leaf up, with the side (L or R) each stands on, and leads to root. A proof names only hashes, never another workspace.

  5. The authority's own signature, with OpenSSL. The anchor's root is the hash the authority signed, so it is given as the digest, with a CA bundle that holds the authority's root certificate (most systems' own bundle does):

    openssl ts -verify -digest <root> -in anchors/<day>.tsr -CAfile <CA bundle>
    openssl ts -reply -in anchors/<day>.tsr -text # what a time stamp says

    The authority is named on each anchor (tsaUrl), and publishes its own certificates.

An entry that passes was written by Reelwire and has not changed since. An entry a time stamp covers existed, as it is, at the moment the authority signed: nobody, Reelwire included, could have written or changed it afterwards without breaking the chain that leads to that time stamp. Entries newer than the last time stamp are sealed and signed, and wait for the next one.

The audit log​

GET /v1/compliance/audit is the workspace's own audit log: who changed what, who was added and given which permissions, which keys were made, what each post went through.

{
"entries": [
{
"id": "01M3B5E...",
"occurredAt": "2026-10-15T08:29:51.010Z",
"actor": "lena@orchid.example",
"actorKind": "person",
"action": "approval.decided",
"entityType": "ApprovalRequest",
"entityId": "01M3B5D7...",
"fromState": "PENDING",
"toState": "APPROVED",
"detail": { "kind": "POST", "posts": 1, "note": "Figures checked." }
}
],
"total": 37,
"all": 9120,
"page": 1,
"perPage": 25,
"facets": { "who": ["all"] }
}

It takes since, until, q (an action, a person or key, or an id), page, perPage and ascending, as the entries do. By default it lists what a person or a key did (actorKind person or key); who=all adds Reelwire's own steps with every post (system), which are most of it.

Approval rules​

On Enterprise a channel that asks for approval can ask for two approvers: two different people holding Approvals each approve a post, or an update, before it goes out. Whether a channel asks for approval at all is its approval on Channels; approvers there says the number, and this sets it.

curl -X PUT http://localhost:4000/v1/compliance/approval-rules/01M1VFRWE7EWSY5D7QDQ0KNK1S \
-H "Authorization: Bearer rws_your_session" \
-H "Content-Type: application/json" \
-d '{"approvers":2}'
{ "channel": { "id": "01M1VFRWE7EWSY5D7QDQ0KNK1S", "name": "Orchid Invest DE", "approval": "PER_POST", "approvers": 2 } }

approvers is 1 or 2; anything else is a 400, and a channel that is not this workspace's or was removed a 404. It needs a person holding Approvals, since lowering it loosens a control. It applies to what starts waiting from then on; anything already waiting keeps the number it was asked with. A change is written to the audit log and is an entry of the record.

One more rule holds on Enterprise without a setting: nobody approves their own post or update. See Approvals for how both show on /v1/approvals.

Auditors​

People from outside the workspace who sign in to read the record, its export, the audit log and, without changing anything, the rest of the workspace. They take no seat, are not in GET /v1/team/users, and a workspace has at most 5. They are locked out while the workspace is not on Enterprise.

GET /v1/compliance/auditors answers auditors, each with id, email, displayName, lastLoginAt and createdAt, and max.

POST /v1/compliance/auditors with { "email": "auditor@example.com", "displayName": "Jane Doe" } (the name optional, up to 120 characters) adds one and sends them the email a colleague gets, with a link to set their own password that does not run out. It answers 201 with the auditor and a one-time password, shown in this answer only. A sixth auditor, or an address already used anywhere on Reelwire, is a 409.

POST /v1/compliance/auditors/{userId}/resend sends a new link, which replaces the one before; within a minute of the last one it is a 409. POST /v1/compliance/auditors/{userId}/delete signs them out everywhere, deletes their account, tells them by email and answers { "removed": true }; what they did stays in the audit log. Removing one works on any plan, so a workspace that left Enterprise can still let one out. The team's own routes refuse an auditor with a 409 that points here.