Skip to content
OSOKORO
For developers

Taking your signups with you.

The people on your waitlist are your contacts, not ours. There are four ways to move them into your own application, and you do not need to ask us to enable any of them.

Which one you want

A one-off move

Download the CSV. It is the whole list including answers, referral counts and UTM parameters. On Starter, Everything and Studio.

Keeping your own database in step

Poll the REST API with updated_since. This is the one to build on. On Everything and Studio.

Reacting the moment somebody joins

Point a webhook at your own endpoint. Signed, retried, and you can see every delivery.

Wiring it to something you did not write

Zapier, for the tools that have no API worth calling.

The CSV

On a waitlist’s Signups screen, choose Export. One row per signup, one column per question, and a byte-order mark so Excel reads UTF-8 names correctly rather than mangling them. Values that begin with =, +, - or @ are prefixed, so a spreadsheet treats an answer as text instead of a formula.

Good for a migration you do once. For anything ongoing, use the API — a CSV is a snapshot and cannot tell you that somebody confirmed their address yesterday.

Authenticating to the API

Create a key under Settings → API keys. It is shown once. Put it in an environment variable rather than typing it into a terminal — a pasted key ends up in your shell history — and send it as a bearer token:

export OSOKORO_API_KEY=…            # the key you were just shown

curl https://osokoro.com/api/v1/signups?project=your-waitlist \
  -H "Authorization: Bearer $OSOKORO_API_KEY"

Keys are scoped to the organisation that created them and carry no per-project scope — pick the waitlist with project. The plan is checked on every request rather than when the key was minted, so a key stops working the moment an organisation loses API access and starts working again if it returns. Revoking a key takes effect immediately.

Every response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset, so you can slow down before you are told to. The hourly allowance is Everything: 1,000, Studio: 10,000. Every response also carries x-request-id; quote it if you report a problem and we can find the request.

GET /api/v1/signups

ParameterAcceptsWhat it does
projectrequiredThe waitlist's slug, as it appears in its page URL.
limit1–500, default 100How many rows to return.
cursoropaqueThe next_cursor from the previous page. Omit for the first.
sortcreated_at | updated_atWhich timestamp orders the results. Defaults to created_at.
updated_sinceISO 8601Only rows changed at or after this instant. This is what makes an incremental sync possible.
verifiedtrueOnly signups that have confirmed their email address.

A page looks like this:

{
  "data": [
    {
      "id": "8f0c…",
      "email": "someone@example.com",
      "verified": true,
      "verified_at": "2026-08-02T09:14:22.104Z",
      "position_code": "8F2QK",
      "referral_count": 3,
      "referred": false,
      "answers": { "price": "$10", "need": "Offline mode" },
      "utm": { "utm_source": "producthunt" },
      "created_at": "2026-08-01T18:02:11.882Z",
      "updated_at": "2026-08-02T09:14:22.104Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJzb3J0Ijoi…",
  "request_id": "5c1f…"
}
FieldMeaning
idStable identifier. Use it as your own foreign key.
emailThe address as submitted.
verifiedWhether they clicked the link in their confirmation email.
verified_atWhen they did, or null.
position_codeTheir referral code — the one their share link carries.
referral_countHow many confirmed signups came from their link.
referredWhether they arrived through somebody else's link.
answersAn object keyed by your question keys.
utmThe UTM parameters present when they arrived.
created_atWhen they joined.
updated_atWhen the row last changed. Drive your sync from this.

Reading the whole list

Follow next_cursor until has_more is false. The cursor is a keyset over the sort field and the row id, so rows sharing a timestamp — which a bulk import produces by the thousand — are never skipped at a page boundary and never returned twice.

let cursor = null;
const everyone = [];

do {
  const url = new URL("https://osokoro.com/api/v1/signups");
  url.searchParams.set("project", "your-waitlist");
  url.searchParams.set("limit", "500");
  if (cursor) url.searchParams.set("cursor", cursor);

  const response = await fetch(url, {
    headers: { authorization: `Bearer ${process.env.OSOKORO_API_KEY}` },
  });
  if (!response.ok) throw new Error((await response.json()).error.code);

  const page = await response.json();
  everyone.push(...page.data);
  cursor = page.next_cursor;
} while (cursor);

Staying in step afterwards

Store the highest updated_at you have seen and pass it back as updated_since, sorting on updated_at. That returns rows which changed as well as rows which are new — so you learn that somebody confirmed their address or unsubscribed, which a query on created_at can never tell you.

const url = new URL("https://osokoro.com/api/v1/signups");
url.searchParams.set("project", "your-waitlist");
url.searchParams.set("sort", "updated_at");
url.searchParams.set("updated_since", lastSyncedAt);  // ISO 8601

Use id as the key on your side and upsert. Signups are never renumbered, so an id you stored last month still refers to the same person.

When something goes wrong

Every failure has the same shape — { "error": { "code", "message", "request_id" } } — so you can branch on code and never on prose. A waitlist that belongs to another organisation answers exactly as one that does not exist; that is deliberate, so a key cannot be used to discover somebody else’s slugs.

StatusCodeMeaning
401unauthorizedThe key is unknown, revoked, or a test key sent as a live one.
402plan_without_apiThe organisation's plan does not include API access.
429quota_exceededThe hour's requests are spent. retry-after says how long to wait.
400project_requiredNo `project` parameter.
404not_foundNo waitlist with that slug on this account.
400invalid_limit · invalid_sort · invalid_updated_since · invalid_cursorThe named parameter was not usable. The message says what it should be.
503unavailableWe could not check the key. Retry; this is not a rejection.

Webhooks, for the moment it happens

Add an endpoint under a waitlist’s settings and choose which events it should receive:

  • signup.created
  • signup.verified
  • signup.unsubscribed
  • perk.claimed
  • broadcast.sent

The body is { "event", "data" }. Each delivery carries x-osokoro-event and x-osokoro-signature, which holds a timestamp and an HMAC-SHA256 of timestamp.body keyed on your endpoint’s signing secret. Verify it before trusting a request, and compare in constant time. A failed delivery is retried on a widening schedule for about half a day, and every attempt is listed with its status so you can see what happened rather than guess.

Webhooks tell you about changes. They are not a way to read the list you already have — pair them with one full pass through the API, or with the CSV.

Deleting your data

Taking a copy does not oblige you to leave one behind. Deleting an organisation under Settings removes its waitlists, signups and answers; what remains is an aggregate record with counts and no addresses. See the privacy notice for what that record holds and why.