Disclosure: I'm the founder of CollectSocials. This is an engineering write-up of a data-modeling problem I ran into while evaluating UGC-rights tools, including building our own. I've kept it to what I could reproduce and to schema-level reasoning, not vendor bashing.
This is a rewritten, developer-focused version of an article originally published on our blog. Canonical source: collectsocials.com/blog/ugc-rights-consent-deletion-test.
If you display customer photos on a website or an event wall, you need the creator's permission — and, more importantly for this post, you need a record of that permission that survives the day someone disputes it. That record is the entire product. A rights tool that collects approvals but can't prove them later is storing a feeling, not evidence.
So this is a post about a data model. Specifically: the most common way the UGC industry records consent has a silent failure mode baked into its schema, and no amount of good UI fixes it, because the problem is where the source of truth lives.
Let me show you the failure first, then the model that closes it.
The common design: consent as a pointer to a mutable, externally-owned object
The dominant pattern for "get UGC rights" works like this:
- Ingest posts that match a hashtag or mention into a
rightstable. Each row startsunrequested. - To ask for permission, you post a public comment on the creator's post asking them to reply with an approve-hashtag (e.g.
#yesagree) and to @mention your brand account. - A backend polls for that reply. When it sees the hashtag + mention, it flips the row to
approvedand stores the reply text and a couple of dates.
Reduced to a schema, the stored consent record is roughly:
-- The "consent record" in a comment-based flow
CREATE TABLE rights (
id bigint PRIMARY KEY,
post_ref text, -- pointer to the IG post
status text, -- 'unrequested' | 'pending' | 'approved'
reply_text text, -- e.g. "@brand #yesagree"
requested_at timestamptz,
approved_at timestamptz
);
Look at what status = 'approved' is actually backed by. It's backed by the continued existence of a public comment on a platform you don't control, owned by an account that isn't yours. The row is a pointer. The thing it points at is mutable and externally owned.
There is no column that can represent "the creator took it back," because in this model there is no server-side event when they do. The consent lives in a comment; the comment's deletion generates no webhook to your system.
Reproducing the failure: delete the consent, watch the record lie
This is the test you can't run by reading a marketing page, because you need both sides of the conversation. I controlled a brand account and a creator account, so I could approve a request and then act as the creator who changes their mind.
- Approved the request. Row went green:
approved. - Then, as the creator, I did the single most ordinary thing a creator can do: I deleted my approval comment on Instagram — the one artifact that was the consent.
Back in the dashboard, the row still said Approved. Nothing re-checked, nothing flagged. There was no revoked state to move to.
The record outlived its own evidence.
I want to be precise about what this is and isn't, because it's easy to overstate:
- This is a legal-evidence weakness, not a platform-terms violation. Nobody broke a rule. The record is simply asserting a fact whose only backing has evaporated.
- Under a consent-withdrawal regime like GDPR, withdrawing consent is a first-class right. If your data model can't represent withdrawal, you can't honor it, and you can't prove you honored it.
The deeper point for engineers: a status field is only as trustworthy as the thing that can flip it. If the source of truth for approved is an object outside your system that a third party can delete without telling you, your status column is a cache with no invalidation.
(One more architectural note from the same test, since it's the same lesson: the comment gets posted by a browser extension acting as your own logged-in account, not a vendor API app. So whatever platform-automation risk that carries lands on your account, not the vendor's. Different failure, same theme — know which system state is authoritative and who carries it.)
The fix: make consent a first-class event in an append-only ledger you own
The correction is to stop pointing at an external mutable object and instead capture the consent event, server-side, at the moment it happens, into a log that only ever grows.
Two tables. One is a mutable projection of current state (convenient to query). The real source of truth is the second: an append-only event log.
-- Current-state projection (fast to read; NOT the source of truth)
CREATE TABLE rights_requests (
id uuid PRIMARY KEY,
post_id uuid,
post_snapshot jsonb, -- the post FROZEN at request time
request_token text, -- 256-bit capability; the signed link
terms_version text, -- which version of the terms was shown
status text, -- pending | approved | declined | revoked
evidence_level text, -- attested | verified | email_confirmed
responder_handle text,
responder_email text,
requested_at timestamptz,
responded_at timestamptz,
revoked_at timestamptz
);
-- Source of truth: append-only. Nothing here is ever UPDATEd or DELETEd.
CREATE TABLE rights_events (
id uuid PRIMARY KEY,
request_id uuid REFERENCES rights_requests(id),
event_type text, -- requested | approved | declined | revoked
-- | email_confirmed | copy_emailed
evidence jsonb, -- server-captured proof for THIS event
created_at timestamptz DEFAULT now()
);
Every transition is an INSERT into rights_events. The status column on rights_requests is just a denormalized read of the latest event — a convenience, never the authority. You can rebuild current state by folding the events; you can never silently rewrite history, because there's no UPDATE/DELETE path on the log. That "including by us" property is the whole point: the vendor can't quietly edit the trail either.
What each event actually captures
The evidence has to be captured at the moment of consent, on the server, never reconstructed later and never trusted from the client. Here's the shape of an approved event's evidence, and why each field is there:
{
"method": "consent_link",
"terms_version": "2026-07-18",
"terms_sha256": "9f2c…", // hash of the EXACT terms text the creator saw
"ip_hash": "sha256(salt + ip)", // salted; "same responder" proof, not IP recovery
"user_agent": "Mozilla/5.0 …",
"handle": "@creator",
"email": "creator@example.com"
}
-
terms_sha256is the load-bearing field. The creator taps approve on a page that renders real, versioned terms. We hash the exact text served and stamp it into the event. Terms are append-only too: changing wording bumps the version and keeps every old version immutable, so you can always reconstruct precisely what a given creator agreed to. You can't do this if "the agreement" was a hashtag in a comment — there was no text. - Server timestamp, never client-supplied. Client clocks lie.
-
ip_hashis salted SHA-256. It exists to answer "was this the same responder?" in a dispute, not to recover anyone's IP. - A
copy_emailedevent fires when we send the creator a copy of exactly what they agreed to. That's deliberate: now a contemporaneous record exists in a place we cannot touch — their mail provider. It's the same trick a DocuSign completion certificate uses. When someone asks "isn't your proof just a row in your own database?", the honest answer is: yes, and so is every e-signature product's — courts accept those as business records because the process is credible and tamper-resistant, not because they're on a blockchain. A copy outside your control is a cheap, boring, real third-party anchor.
Withdrawal becomes a real event, not an absence
The bug that started this post is now structurally impossible. Revocation isn't the disappearance of evidence — it's a new row:
INSERT INTO rights_events (request_id, event_type, evidence)
VALUES ($1, 'revoked', '{ "method": "consent_link", ... }');
The creator reopens the same signed link they approved on and taps withdraw. status moves to revoked, a revoked event is appended, the serving path (a posts.rights_status mirror the public feed filters on) drops the post immediately, and — if they left an email — they get a copy confirming the withdrawal. The audit trail gains information when consent is withdrawn instead of silently going stale.
A note on self-approval, since someone always asks
Anyone holding the capability link can respond, so in principle a brand user could approve their own request. We don't block it — blocking creates false negatives at live events (shared Wi-Fi, etc.). Instead we make it tamper-evident: the requester's ip_hash/user-agent are captured at create time, and if an approval arrives from the same ip_hash or within a few minutes of the link being minted, the event records self_response_suspected with the reasons. A brand that fakes consent against itself just writes documented evidence of doing so. Honesty over tidiness.
What an audit trail has to capture to survive a takedown
Strip away the specifics and here's the checklist I'd apply to any consent/audit system, not just UGC:
-
Is the source of truth append-only? If a status can be
UPDATEd in place, the "audit trail" can be rewritten — by the vendor, or by anyone with DB access. - Is the evidence captured server-side at the moment of the event? Client-supplied timestamps and "we'll reconstruct it later" are not evidence.
- Does it hash what was actually agreed to? A version pointer isn't enough if the versioned text is mutable. Hash the served bytes.
- Can the subject withdraw, and does that generate an event? Not a deletion. An event.
- Does a copy live somewhere you can't alter? A row in your own DB paired with a copy in the subject's inbox is far stronger than the row alone.
- Is the authoritative state something you own? If it's a pointer to a mutable object on someone else's platform, it's a cache pretending to be a record.
The comment-based model fails 1, 3, 4, and 6 by construction. That's not a bug you can patch in the UI. It's the schema.
Try the failing test yourself
You don't have to take my word for any of this. On a free trial of whatever UGC-rights tool you're weighing, run the one test that tells you the most: approve something, then remove the approval the way a real creator would, and watch whether the record notices. Runs in five minutes. It's the fastest way to find out whether you're buying evidence or a green checkmark.
If you want the version built around an append-only ledger with hashed versioned terms, a real withdrawal event, and a copy in the creator's own inbox, that's what we shipped at CollectSocials — and the full write-up with the screenshots walks the same test end to end.
Top comments (0)