Writing to a Supabase table over the REST API has two traps that fail quietly: PostgREST matches JSON keys to column names exactly, so one camelCase field breaks every insert, and the newer sb_secret_… keys are not JWTs, so they belong in the apikey header; anything that verifies the Authorization header as a JWT, such as Edge Function code, fails on one. Both failures happen on Supabase's side of the request, not in your code, and neither necessarily produces an error in your app.
The short version
- PostgREST maps a JSON key to a column of the same spelling.
receivedAtneeds a column created as"receivedAt", with the quotes. Unquoted, Postgres folds it toreceivedatand every insert fails withPGRST204. - Legacy
service_rolekeys are JWTs and go in bothapikeyandAuthorization: Bearer. The newersb_secret_…keys are opaque, not JWTs, and belong inapikeyalone. - Both failures happen on Supabase's side of the request, not in your code. If your write helper swallows errors — a reasonable thing for it to do — your application reports success and loses the row.
Trap 1: PostgREST does not convert your casing
Post a body to /rest/v1/<table> and PostgREST takes each JSON key as a column name verbatim (PostgREST, error reference). There is no camelCase-to-snake_case translation step. So this payload:
{ "email": "a@example.com", "receivedAt": "2026-09-11T22:20:02.865Z" }needs a column literally named receivedAt. Postgres folds unquoted identifiers to lower case (PostgreSQL, identifiers), so the obvious DDL creates the wrong thing:
-- Creates "receivedat". Every insert above now fails.
create table public.leads (receivedAt timestamptz);
-- Creates "receivedAt". Correct.
create table public.leads ("receivedAt" timestamptz);The error is specific once you see it:
PGRST204 Could not find the 'receivedAt' column of 'leads' in the schema cache
Two ways out, and the choice is worth making deliberately. Quote the identifier and keep the casing your application already sends, which is what we did — it keeps the write path free of a mapping layer, at the cost of a column you must always quote in SQL. Or use snake_case columns and translate in the client, which is tidier in the database and adds a place where a field can be forgotten.
Either is fine. Choosing neither, and discovering the mismatch from production inserts, is the expensive option.
Trap 2: the new keys are not JWTs
Supabase's legacy anon and service_role keys are HS256 JWTs, so either one works in Authorization: Bearer, including the usual shape of sending the same key in both apikey and Authorization: Bearer.
The newer keys do not have that shape. sb_secret_… and sb_publishable_… are opaque strings. Supabase allows one in Authorization: Bearer only when it exactly matches the apikey header, as a backward-compatibility allowance (Supabase, discussion #29260), and anything that verifies that header as a JWT fails on it (Supabase, API keys).
A helper that sends the same key in both headers keeps working on the REST API after rotation. What breaks is anything that puts a new-format key in Authorization: Bearer next to a different apikey value, or hands that header to code that expects a JWT. Send new-format keys on apikey alone, as Supabase recommends:
export function authHeaders(key: string): Record<string, string> {
const opaque = key.startsWith("sb_");
return opaque
? { apikey: key }
: { apikey: key, Authorization: `Bearer ${key}` };
}Two things worth saying about that function. It decides transport, never privilege — a publishable key takes the same path and runs as the anon role, so your RLS policies decide what it can do. And it keeps the code on the header shape Supabase documents before the legacy keys are retired, which Supabase currently plans for late 2026.
Why both of these are quiet
A write helper that never throws is good design. A capture failure should not take down a request that had other work to do. But "never throws" and "never reports" are different, and it is easy to ship the first while accidentally shipping the second:
const stored = await recordLead(lead); // null on success, a code on failure
if (stored !== null && stored !== "NOT_CONFIGURED") {
console.error("[contact] lead store failed", { stored });
}Without that check, whenever the rest of the request succeeds, a store refusal returns the same 200 as a healthy write, and nothing records why the row is missing.
How to verify, in about a minute
Do not trust your own success path. Go to the data.
-- Every column, with its real spelling.
select column_name, data_type
from information_schema.columns
where table_schema = 'public' and table_name = 'leads';Then write one row through the real endpoint and select it back. On our lead store this check confirmed both: the quoted receivedAt column accepted the value, and a publishable key sent with apikey alone reached PostgREST and was refused by RLS — [] on select, 42501 on insert. That second result is the one worth copying. It proves the key format reaches the database and that the table is closed to anything but the server's own key.


Maestro development preview · synthetic session data