Security
Sensitive data, private storage, signed URLs, tokens and authorization inside tools.
Where data lives#
Sendo has no server of its own. Messages, contacts, media and raw webhooks are stored only in your Supabase project (sendo_* tables and your Storage bucket), behind your RLS. Quami never receives them. For sensitive data (health, finance, minors) that means your app is the data controller for WhatsApp content too: apply the same policies you apply to the rest of your records.
Private bucket and signed URLs#
- Inbound media is downloaded from Meta right away (Meta’s URLs expire in minutes) and stored at
{tenant_id | _}/{account_id}/{conversation_id}/{meta_media_id}.{ext}inSENDO_STORAGE_BUCKET. - The bucket must be private (
public = false).doctorchecks that it exists, not its visibility, so check it in Supabase Studio. - The inbox only shows media through signed URLs valid for 300 seconds (
sendo.inbox.thread). Don’t persist signed URLs or make objects public to “fix” previews. - Don’t add Storage policies that let browser clients read the bucket directly.
Retention and encryption are yours#
Sendo prunes only sendo_events (raw webhook payloads, 30 days by default). It never deletes sendo_messages or sendo_media, and it doesn’t encrypt message content:
- Decide how long conversations and files are kept and delete them on a schedule (see the example in Retention).
- Encryption at rest is your database’s and storage provider’s (Supabase encrypts disks and backups). If you need column-level encryption, add it in your app; note that
sendo_messages.textholds a plain-text copy of the body for previews and search. - Raw payloads in
sendo_events.payloadcontain message text and phone numbers; keepeventRetentionDaysas short as your reprocessing needs allow.
Authorization inside tools#
The agent’s context is limited to one contact and one conversation, and tool input is validated with zod before run. That doesn’t make the input trustworthy: a user can write anything, including instructions aimed at the model (prompt injection). So:
- Authorize in
runfrom the context Sendo gives you (contact.appRef,account.tenantId), never from ids the model passes in. - Handle
contact.appRef === null(unlinked contacts): ask for an identifier and verify it, don’t guess. - Return only what this contact may see. Tool results go back to the model.
- Give tools the narrowest scope: read-only where possible, no generic SQL.
const agendarCita = agentTool({
description: "Agenda una cita para el paciente vinculado a esta conversación",
input: z.object({ fecha: z.iso.date(), hora: z.string().regex(/^\d{2}:\d{2}$/) }),
// `input` is already validated (and typed) by the zod schema.
async run({ input, contact, account }) {
// Authorize inside the tool: only act on the entity linked to THIS contact.
if (contact.appRef === null) {
return { error: "Paciente no identificado. Pide su número de DPI." };
}
return bookAppointment({
tenantId: account.tenantId,
patientId: contact.appRef,
date: input.fecha,
time: input.hora,
});
},
});Tokens and secrets#
sendo_accounts.token_refstores the name of an env var or secret. Tokens are resolved per call throughresolveToken(account)and never written to the database.- Logs are redacted: the logger strips
Authorizationheaders,access_tokenparameters, the app secret, the verify token and every token it resolved.MetaApiErrormessages are redacted too, andtoJSON()omitscauseandstack.doctornever prints a token. SUPABASE_SERVICE_ROLE_KEYbypasses RLS: server only, never in aNEXT_PUBLIC_*variable.
Webhook verification#
POSTs are checked with HMAC-SHA256 of the raw body against X-Hub-Signature-256 using your app secret, compared in constant time; a bad signature returns 401 and stores nothing. The GET verify token is also compared in constant time.
Database access#
RLS is enabled on every sendo_* table, with only SELECT policies through sendo_can_access_tenant(tenant_id) (default false). Events, locks and schema metadata are service-role only, and browser clients can’t write. Keep it that way: writes go through server actions that call your auth check (Inbox).