Migrating existing data
Switching an existing app onto dropin has a cold-start problem: your users already follow each other and already have history, but a fresh tenant is empty — every timeline renders blank on day one. The batch API fixes that. Three routes, each taking up to 100 items per call, let you backfill everything before launch:
batch.users— the people (and any other actors).batch.follows— the follow graph, so fan-out has edges to fan out along.batch.activities— the history, which fans out to the followers you just created.
That order matters: follows reference users, and an activity posted before its author has followers reaches nobody’s timeline. Users → follows → activities, always.
Batch is server-token only — it lives in @dropinnodex/server, never in
the browser SDKs. A user-token call to any batch route returns FORBIDDEN.
Quiet by design
Section titled “Quiet by design”Imported history fills feeds but doesn’t announce itself:
- No notification-feed events are created for batch follows.
- No live-mode “new activity” pings fire for batch activities.
- No webhooks are delivered for any batch operation — your backend already has this data; an import must not fire thousands of deliveries back at it.
Fan-out itself runs normally (that’s the point — the rows land in your users’ timelines), it just doesn’t scream while doing it.
Importing history vs mirroring live writes
Section titled “Importing history vs mirroring live writes”Quiet is right for backfilling and wrong for anything happening now. The two paths write identical rows and differ only in whether anyone is told:
| You are… | Use | Notification | Webhook |
|---|---|---|---|
| Backfilling an existing social graph | batch.userFollows([…]) |
no | no |
| Mirroring a follow a user just made | userFollow({ follower, following }) |
yes | follow.added |
| Importing old posts | batch.activities([…]) |
no | no |
| Mirroring a post just created | feed(…).addActivity(…) |
yes | activity.added |
Both columns are server-token calls from your backend — the loud path does not need a user token. If you wire a database trigger or a webhook from your own system to mirror ongoing writes, that is the loud path, not batch.
The loud path is idempotent too: re-following an existing edge writes nothing and notifies nobody, so an at-least-once trigger can safely deliver twice.
Per-item results
Section titled “Per-item results”A batch call is not atomic. One bad row never rolls back the rest: the response is HTTP 200 whenever the envelope was valid, with one result per input item, in order:
{ "results": [ { "index": 0, "ok": true, "id": "b3f0…" }, { "index": 1, "ok": false, "code": "VALIDATION_FAILED" }] }Check every results[i].ok. A failed item carries only a stable error code
(never a message) — collect the failed indexes, fix the source rows, and
retry just those.
Idempotency — make reruns safe
Section titled “Idempotency — make reruns safe”Import scripts crash halfway. Design for the rerun:
- Users are upserts — running the same batch twice is a no-op.
- Follows deduplicate on the edge — a duplicate follow is
ok: true, not an error. - Activities deduplicate on
foreign_id+time. Supply both on every imported activity —foreign_idfrom your own primary key,timefrom the original timestamp — and a rerun is a no-op. Without them, a rerun duplicates the history. Don’t skip this.
This is the same (foreign_id, time) dedupe rule that governs every activity
write — the Idempotency & foreign_id
section of the activities guide
spells out the full semantics (including the burned-identity 409).
time doing double duty is a bonus: it carries the historical timestamp, so
imported activities sort into feeds where they actually happened, not at the
top of everyone’s timeline on import day.
Rate limit
Section titled “Rate limit”Batch requests charge a separate window — 600 batch requests per minute
per tenant (a 60,000-item/min ceiling) — so a running import never starves
your app’s normal traffic, and vice versa. A 429 carries Retry-After as
usual; a few thousand users’ worth of history won’t get near the limit.
That works out to 60,000 items/min. For the arithmetic on a specific backfill size, and every other limit the API enforces, see Limits.
A complete import script
Section titled “A complete import script”FC Urban-shaped example: a five-a-side football app migrating players and
their played games. Each player has a user:<id> feed (their own posts) and a
timeline:<id> feed (who they follow); a finished game becomes a played
activity on the organizer’s feed.
import { DropInServer, type BatchResultItem } from '@dropinnodex/server'import { players, followEdges, games } from './legacy-db.js' // your existing data
const server = new DropInServer({ tenantId: 'fc-urban', apiKey: process.env.DROPIN_API_KEY!, apiSecret: process.env.DROPIN_API_SECRET!,})
function chunk<T>(items: T[], size = 100): T[][] { const out: T[][] = [] for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)) return out}
// Run one chunked import; return the source items whose rows failed, for retry.async function importAll<T>( label: string, items: T[], send: (batch: T[]) => Promise<{ results: BatchResultItem[] }>,): Promise<T[]> { const failed: T[] = [] for (const batch of chunk(items)) { const { results } = await send(batch) for (const r of results) { if (!r.ok) { console.error(`${label}[${r.index}] failed: ${r.code}`) failed.push(batch[r.index]) } } } console.log(`${label}: ${items.length - failed.length}/${items.length} ok`) return failed}
// 1. Users first — everything else references them.const failedUsers = await importAll('users', players.map((p) => ({ id: p.id, custom: { name: p.name, position: p.position } })), (batch) => server.batch.users(batch),)
// 2. Follows next — fan-out needs the graph before history is posted.// `userFollows` takes plain user ids and expands the feed convention for you// ("maya follows diego" → timeline:maya pulls from user:diego). Reach for the// raw `batch.follows({ source, target })` form only for non-user feed graphs.const failedFollows = await importAll('follows', followEdges.map((e) => ({ follower: e.follower, following: e.followed })), (batch) => server.batch.userFollows(batch),)
// 3. Activities last — they fan out to the followers created in step 2.// foreign_id + time make every activity a safe rerun (and sort it correctly).const failedActivities = await importAll('activities', games.map((g) => ({ feed: `user:${g.organizerId}`, activity: { actor: g.organizerId, verb: 'played', object: `game:${g.id}`, foreign_id: `game:${g.id}`, // your primary key — dedupes reruns time: g.playedAt.toISOString(), // the real date — sorts into history custom: { venue: g.venue, score: g.score }, }, })), (batch) => server.batch.activities(batch),)
if (failedUsers.length || failedFollows.length || failedActivities.length) { // Fix the source rows and re-run — every step is idempotent, so a full // rerun is also safe. Nothing needs cleaning up first. process.exit(1)}Because every step is idempotent, the simplest retry strategy is often the
best one: fix whatever produced the failed rows and run the whole script
again. Rows that already landed come back ok: true and nothing duplicates.
What batch is not
Section titled “What batch is not”No batch deletes, no batch reactions, no CSV upload, no async job tracking —
batch is a cold-start import tool, not a sync pipeline. Once you’re live,
writes go through the normal single-op routes and SDKs — including from your
backend: server.userFollow(…) and server.feed(…).addActivity(…) are
server-token calls that behave exactly like a user’s own write.
Related
Section titled “Related”- Follow — the loud counterpart for follows that happen live.
- Activities — the
(foreign_id, time)identity that makes a rerun safe, and the409a deleted identity returns. - Limits — the batch rate window and the arithmetic for a given backfill size.
- Webhooks — why an import stays silent.