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.
| Method | Path | Permission |
|---|---|---|
GET | /v1/compliance | compliance:read |
GET | /v1/compliance/records | compliance:read |
GET | /v1/compliance/records/{id} | compliance:read |
GET | /v1/compliance/export | compliance:read |
GET | /v1/compliance/audit | compliance:read |
PUT | /v1/compliance/approval-rules/{channelId} | signed-in person: Approvals |
GET | /v1/compliance/auditors | signed-in person |
POST | /v1/compliance/auditors | signed-in person: Administration |
POST | /v1/compliance/auditors/{userId}/resend | signed-in person: Administration |
POST | /v1/compliance/auditors/{userId}/delete | signed-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.
onis whether the workspace records now.lapsedisnull, or, for a workspace that left Enterprise,since(when) anddeletesOn(when what it recorded is deleted).entriescounts them,sealedthose that have their number and signature, andwaitingthose sealed within the next seconds.chainis the newest entry's number and hash.anchoris the newest daily time stamp that covers this workspace, up to which entry (seq), and itsstate:PENDINGwhile the authority is asked again, every ten minutes or so,STAMPEDwith the moment it signed (genTime), orFAILEDonce it was asked 48 times without an answer.keysare 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:
| Parameter | What it does |
|---|---|
since, until | Only entries that happened in this window, ISO 8601 instants. |
kind | One kind or several, comma separated (below). |
channelId | One channel or several, comma separated. |
sha256 | Only 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. |
q | A post key, a post id, an entry's hash, or words of a caption or a channel's name. |
page, perPage | perPage 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.
kind | Label | Written when |
|---|---|---|
post.publish | Handed over | A post went out on one channel. One entry per channel of a hand-over. |
post.update | Update handed over | A change to a post that went out was handed over. |
post.delete | Delete handed over | A request to take a post down was handed over. |
post.resend | Sent again | A resend by email, by webhook or to syndication participants, a repost through the platform connector, or a retry of a post that failed. |
approval.decision | Approval decision | Somebody approved or rejected a post or an update, or gave the first of two approvals. |
delivery.outcome | Delivery outcome | An email, a webhook call or a platform connector post was delivered, or failed after every attempt. |
post.report | Reported | A post was said to be published (with its link), skipped, not posted or deleted. |
channel.approval | Approval rule changed | A 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 itsways(EMAIL,WEBHOOK,CONNECTOR,SYNDICATION) and, on a resend,again, saying which; the post (withpermalinkwhere one was known), the channel, the brand, the template with the fingerprint of itsbuild, thesource, theauthor(a person, an API key, orfeedfor 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), theapprovalwith every decision, thevideoandcoverwith their SHA-256, thetext, 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, theapprovalthe change went through, and thechangeitself: its text, its captions and, where it replaced the video, the newvideo. - A delete: the channel and post, and
requestedBy. - An approval decision:
approvalwithabout(postorupdate),decision(approved,rejectedorfirst-approval),needed,by,note,requestedBy(the author) and, for an update, thechangeapproved. - A delivery's outcome:
deliverywith itsway,outcome(deliveredorfailed),attempts, the webhook's or platform'starget, the laststatusand anote. - A report:
reportwith the post'sstate(PUBLISHED,SKIPPED,FAILEDorRECALLED),by, and theurl,platformPostIdorreasongiven. - An approval rule changed: the channel,
by, andfromandto, each itsapprovalandapprovers.
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"
| Parameter | What it does |
|---|---|
since, until | The 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. |
channelId | Some 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.
| File | What it is |
|---|---|
report.pdf | The 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.json | entries, 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.mjs | Every check below, written out. Node.js 18 or newer, nothing to install. |
README.txt | What 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:
-
Each entry's content.
bodyHashis the SHA-256, in hexadecimal, ofbodyas canonical JSON: keys sorted at every level, nothing undefined, no spaces. -
Each entry's seal.
entryHashis the SHA-256 of the canonical JSON of an object holding the workspace's id (tenantId,workspace.idinrecords.json),seq,prevHashandbodyHash.signatureis Reelwire's Ed25519 signature, in base64, over the 32 bytes ofentryHash, checked with the key inkeysthatkeyIdnames. -
The chain. Numbers follow on without a gap, and each link's
prevHashis theentryHashof 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. -
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,seqandheadHashjoined by line feeds; a node is the SHA-256 of a 1 byte followed by the two hashes' 32 bytes each. Each anchor'sproof.pathlists the sibling hashes from the leaf up, with the side (LorR) each stands on, and leads toroot. A proof names only hashes, never another workspace. -
The authority's own signature, with OpenSSL. The anchor's
rootis 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 saysThe 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.