Retention

Pruning sendo_events with pg_cron or a Vercel Cron route, your own policy for messages and media, exporting a number's history (v1 JSON), the numbers screen's Export conversations and Delete history (allowPurge), and purging a disconnected number.

sendo_events (built in)#

Every webhook POST is stored raw in sendo_events so failed events can be reprocessed. Rows older than 30 days should be deleted. Pick one of the two options below; running both is harmless.

Option A: pg_cron#

If the pg_cron extension is enabled when the schema migration runs (Supabase: Database › Extensions), the migration schedules a daily job:

sql
-- Scheduled by the schema migration when pg_cron is installed:
select cron.schedule('sendo_prune_events_daily', '17 3 * * *', 'select public.sendo_prune_events(30)');

sendo_prune_events(days) deletes events received more than days ago and returns the count. It is service-role only. If you enable pg_cron later, or want another period:

sql
-- Enabled pg_cron after applying the migration? Schedule it yourself:
create extension if not exists pg_cron;
select cron.schedule('sendo_prune_events_daily', '17 3 * * *', 'select public.sendo_prune_events(30)');

-- Change the retention, inspect or remove the job:
select cron.unschedule('sendo_prune_events_daily');
select cron.schedule('sendo_prune_events_daily', '17 3 * * *', 'select public.sendo_prune_events(14)');
select * from cron.job where jobname = 'sendo_prune_events_daily';

Option B: Vercel Cron#

Without pg_cron, call sendo.maintenance.prune() from a scheduled route. It deletes events older than eventRetentionDays (default 30) and returns the count; pass { olderThanDays } to override.

app/api/cron/sendo-prune/route.ts
// app/api/cron/sendo-prune/route.ts
import { sendo } from "@/lib/sendo";

export const runtime = "nodejs";

export async function GET(request: Request) {
  // Vercel Cron sends `Authorization: Bearer $CRON_SECRET`.
  const secret = process.env.CRON_SECRET;
  if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  const deleted = await sendo.maintenance.prune(); // events older than eventRetentionDays
  return Response.json({ deleted });
}
vercel.json
{
  "crons": [{ "path": "/api/cron/sendo-prune", "schedule": "17 3 * * *" }]
}

Set CRON_SECRET in the Vercel project; Vercel sends it as a bearer token on cron requests. Any other scheduler (GitHub Actions, Supabase scheduled functions) can call the same route.

Messages and media (your policy)#

Sendo never deletes sendo_messages, sendo_media or stored files. How long conversations are kept is your app’s decision (and, for health or financial data, often a legal one). A starting point that deletes files through the Storage API and then the messages (their sendo_media rows cascade):

app/api/cron/sendo-message-retention/route.ts
// app/api/cron/sendo-message-retention/route.ts — YOUR policy for sendo_messages / sendo_media.
import { createClient } from "@supabase/supabase-js";

const RETENTION_DAYS = 365;
const BUCKET = process.env.SENDO_STORAGE_BUCKET ?? "sendo-media";

export async function GET(request: Request) {
  if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET ?? ""}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
    process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
    { auth: { persistSession: false } },
  );
  const cutoff = new Date(Date.now() - RETENTION_DAYS * 86_400_000).toISOString();

  // 1. Delete the stored files first (Storage API, not SQL on storage.objects).
  const { data: media, error } = await supabase
    .from("sendo_media")
    .select("storage_path")
    .lt("created_at", cutoff)
    .not("storage_path", "is", null)
    .limit(1000);
  if (error) throw error;
  const paths = (media as { storage_path: string }[]).map((row) => row.storage_path);
  if (paths.length > 0) await supabase.storage.from(BUCKET).remove(paths);

  // 2. Delete old messages; sendo_media rows cascade.
  const { count } = await supabase
    .from("sendo_messages")
    .delete({ count: "exact" })
    .lt("created_at", cutoff);
  return Response.json({ files: paths.length, messages: count ?? 0 });
}

Exporting a number’s history#

How long you keep conversations, and when you hand them over or delete them, is the operator’s decision: your app’s, not Sendo’s. A clinic may be legally required to keep records for years, or to delete them within a period. Sendo gives you the two tools and never runs either on its own: disconnecting a number keeps everything (read-only), and nothing is deleted until you call purge.

sendo.accounts.export(id, { actor }) returns the whole history of one number, connected or disconnected, as an AsyncIterable<string>: the chunks of a single JSON document. Only admins can export (numbers.manage): any other actor gets ForbiddenError (FORBIDDEN) when you call it, before any chunk. A number of another tenant rejects the first chunk with AccountNotFoundError. id is the account id or the phone number id.

app/api/numbers/[id]/export/route.ts
import { exportFileName } from "@quami/sendo";
import { sendo } from "@/lib/sendo";

export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const actor = await currentActor(); // sendo.actor(userId, tenantId) for the signed-in user
  const chunks = sendo.accounts.export(id, { actor }); // ForbiddenError for non-admins
  const iterator = chunks[Symbol.asyncIterator]();
  const first = await iterator.next(); // AccountNotFoundError before any byte is sent
  const account = await sendo.accounts.get(id);
  const encoder = new TextEncoder();
  const body = new ReadableStream<Uint8Array>({
    start(controller) {
      if (!first.done) controller.enqueue(encoder.encode(first.value));
    },
    async pull(controller) {
      const next = await iterator.next();
      if (next.done) controller.close();
      else controller.enqueue(encoder.encode(next.value));
    },
  });
  return new Response(body, {
    headers: {
      "Content-Type": "application/json",
      "Content-Disposition": `attachment; filename="${exportFileName(account.displayPhone, new Date())}"`,
    },
  });
}

Map ForbiddenError to 403 and AccountNotFoundError to 404 before streaming. The numbers registry item ships this route ready to use (below).

From a terminal, npx @quami/sendo accounts export <id> --out history.json writes the same document (CLI).

  • Streamed. Conversations are read 200 at a time with each thread paged inside, so memory stays flat for numbers with tens of thousands of messages. Write the chunks out as they come.
  • A snapshot. Everything with createdAt <= exportedAt (the moment the export started); messages and conversations written while it runs are left out.
  • Media as references, not files. Each media item has its storageKey and a url signed for 7 days when your media store can sign; otherwise url is null and you fetch it by storageKey. missing: true means the file was never stored (download pending or failed) or the store could not sign it, for example because your own retention job already deleted it.
  • File name. exportFileName(displayPhone, exportedAt) returns sendo-export-50222345600-2026-10-12.json (the number’s digits and the UTC date).

Export format (v1)#

The document is SendoExportDocumentV1 (exported from @quami/sendo). Dates are ISO 8601 strings; metadata is the stored message metadata, so system events keep event, by and their params.

sendo-export-50222345600-2026-10-12.json
{
  "format": "sendo-export",
  "version": 1,
  "exportedAt": "2026-10-12T18:04:11.000Z",
  "sendoVersion": "0.1.0",
  "account": {
    "id": "8f0c…",
    "tenantId": "0aa5680f-…",
    "phoneNumberId": "123456789012345",
    "wabaId": "900000000000123",
    "displayPhone": "+502 2234 5600",
    "label": "Torre Vista",
    "active": false,
    "onboardedVia": "embedded_signup",
    "createdAt": "2026-03-02T15:20:00.000Z"
  },
  "contacts": [
    {
      "id": "c1…",
      "waId": "50255124433",
      "profileName": "Rosa",
      "appRef": "Apto 4B",
      "optIn": { "marketing": true, "at": "2026-05-01T12:00:00.000Z" }
    }
  ],
  "tags": [{ "id": "t1…", "name": "cobranza", "color": "amber" }],
  "conversations": [
    {
      "id": "v1…",
      "contactId": "c1…",
      "status": "closed",
      "assignedTo": "user-42",
      "tagIds": ["t1…"],
      "createdAt": "2026-10-01T09:00:00.000Z",
      "messages": [
        {
          "id": "m3…",
          "direction": "out",
          "sender": "system",
          "senderRef": null,
          "type": "text",
          "text": "Sofia closed the conversation",
          "content": { "body": "Sofia closed the conversation" },
          "internal": true,
          "draft": false,
          "status": "sent",
          "metadata": { "kind": "system", "event": "closed", "by": "user-42", "byName": "Sofia" },
          "sentAt": "2026-10-02T10:00:00.000Z",
          "createdAt": "2026-10-02T10:00:00.000Z",
          "media": []
        },
        {
          "id": "m1…",
          "direction": "in",
          "sender": "contact",
          "senderRef": null,
          "type": "image",
          "text": "My receipt",
          "content": { "id": "300000000000001", "caption": "My receipt" },
          "internal": false,
          "draft": false,
          "status": "received",
          "metadata": {},
          "sentAt": "2026-10-01T09:00:00.000Z",
          "createdAt": "2026-10-01T09:00:01.000Z",
          "media": [
            {
              "id": "md1…",
              "mimeType": "image/jpeg",
              "storageKey": "0aa5680f-…/8f0c…/v1…/300000000000001.jpg",
              "url": "https://…/sign/sendo-media/…?token=…",
              "missing": false
            }
          ]
        }
      ],
      "closedAt": "2026-10-02T10:00:00.000Z"
    }
  ]
}
FieldNotes
accountThe number as stored, active: false once disconnected. No token or tokenRef.
contactsEvery contact with a conversation on the number. optIn.at is when marketing consent was decided (null = never).
tagsThe tenant’s tag catalog; conversations point to it with tagIds.
conversations[].assignedToThe current assignee; past assignments are the assigned / unassigned system events in messages.
conversations[].closedAtWhen the last closed system event was written, for a closed conversation; else null.
conversations[].messagesNewest first (the inbox thread order): contact and outbound messages, internal notes (internal, sender human), system events (internal, sender system) and pending agent drafts (draft).
messages[].mediaZero or one item: references only, never the bytes.

New fields may be added in a later version: 1 export; a breaking change bumps version.

On the numbers screen#

npx shadcn add @sendo/numbers gives admins both tools in the UI, so nobody needs a terminal:

  • Export conversations on every number (connected or disconnected). It is a plain download link to app/(protected)/inbox/numbers/export/[id]/route.ts (numbersConfig.links.export, /inbox/numbers/export/{id}), so the browser shows its own progress for large files. The route calls requireUser(), resolves the actor and streams sendo.accounts.export: a user without numbers.manage gets 403 with no body, an unknown number or one of another tenant 404, and an admin sendo-export-<digits>-<date>.json (Content-Disposition: attachment).
  • Delete history on disconnected numbers, only when you opt in: set allowPurge: true in numbersConfig (app/(protected)/inbox/numbers/numbers-config.ts). The page passes it to <NumbersScreen allowPurge>, and the purge server action checks it again, so the button and the action stay off together. The admin types the full number (digits are compared, so spaces and + don’t matter) before sendo.accounts.purge runs.
app/(protected)/inbox/numbers/numbers-config.ts
export const numbersConfig: NumbersConfig = {
  // …
  allowPurge: true, // show "Delete history" on disconnected numbers
  links: {
    // …
    export: "/inbox/numbers/export/{id}",
  },
};

The disconnect dialog tells admins that the history stays and can be exported. Whether you turn on Delete history, and how long you keep a number’s conversations, is your retention duty as the operator of the app (a clinic may have to keep records for years, or delete them on request); Sendo only provides the tools.

Deleting a number’s history (purge)#

sendo.accounts.purge(id) is separate from export and only works on a disconnected number: it deletes the account, its contacts, conversations, messages, media rows and templates, then the media files. An active number throws AccountStateError (ACCOUNT_STATE) and nothing is deleted, so the order is always disconnect, export if you need a copy, then purge. There is no undo. Whether and when to purge is your retention policy (Deleting an organization’s data).