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
Download the CSV. It is the whole list including answers, referral counts and UTM parameters. On Starter, Everything and Studio.
Poll the REST API with updated_since. This is the one to build on. On Everything and Studio.
Point a webhook at your own endpoint. Signed, retried, and you can see every delivery.
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
| Parameter | Accepts | What it does |
|---|---|---|
| project | required | The waitlist's slug, as it appears in its page URL. |
| limit | 1–500, default 100 | How many rows to return. |
| cursor | opaque | The next_cursor from the previous page. Omit for the first. |
| sort | created_at | updated_at | Which timestamp orders the results. Defaults to created_at. |
| updated_since | ISO 8601 | Only rows changed at or after this instant. This is what makes an incremental sync possible. |
| verified | true | Only 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…"
}| Field | Meaning |
|---|---|
| id | Stable identifier. Use it as your own foreign key. |
| The address as submitted. | |
| verified | Whether they clicked the link in their confirmation email. |
| verified_at | When they did, or null. |
| position_code | Their referral code — the one their share link carries. |
| referral_count | How many confirmed signups came from their link. |
| referred | Whether they arrived through somebody else's link. |
| answers | An object keyed by your question keys. |
| utm | The UTM parameters present when they arrived. |
| created_at | When they joined. |
| updated_at | When 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 8601Use 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.
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | The key is unknown, revoked, or a test key sent as a live one. |
| 402 | plan_without_api | The organisation's plan does not include API access. |
| 429 | quota_exceeded | The hour's requests are spent. retry-after says how long to wait. |
| 400 | project_required | No `project` parameter. |
| 404 | not_found | No waitlist with that slug on this account. |
| 400 | invalid_limit · invalid_sort · invalid_updated_since · invalid_cursor | The named parameter was not usable. The message says what it should be. |
| 503 | unavailable | We 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.