Docs · 11
Developer API
One key and one base URL. Cloud endpoints (chat, computer-use actions, images) answer from Twinly's servers. Mac endpoints (send and read iMessages, place a call in your cloned voice, speak a line in your voice, run a full task) run on the key owner's Mac, in their running Twinly app, under their own approval settings. Every call is charged to the account that owns the key, on its existing plan.
01The basics
| What | Value |
|---|---|
| Base URL | https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1 |
| Auth | Authorization: Bearer twk_live_... |
| Model | twinly-core |
| OpenAPI 3.1 | /openapi.yaml, for Postman, Insomnia, or any client generator |
| Mac endpoints need | Twinly 4.1.2 or later, open on the key owner's Mac |
| Starter projects | github.com/OjasMishra32/twinly-api-examples: six projects with tests and a mock API, no installs |
- A key belongs to one Twinly account and acts as that person: texts come from their number, calls speak in their voice, tasks run on their Mac. Build with your own key on your own Mac.
- Cloud endpoints answer in the request. Mac endpoints create a job the Mac picks up within seconds; you wait on it with
?wait=or get a webhook when it is done. - One usage pool. API calls spend the same plan and extra usage as the app.
GET /v1/usageshows where you are. - Scopes limit each key to what a project needs.
02Which endpoint do I want?
| I want to | Use | Answers |
|---|---|---|
| Ask a model a question, with or without images | POST /v1/chat/completions | now |
| Make a picture | POST /v1/images | now |
| Drive a screen my code controls, one step at a time | POST /v1/computer/next-action | now |
| Text someone (with pictures, audio or files) from my number | POST /v1/messages | job, about 2 s |
| Read the latest messages in a chat | GET /v1/messages | job, about 1 s |
| Get my cloned voice saying something, as audio | POST /v1/voice/speak | job, about 3 s |
| Phone a business and get the transcript | POST /v1/calls | job, minutes |
| Have my Twinly do a whole thing (research, browser, calendar, files, the screen) | POST /v1/tasks | job, seconds to minutes |
| Get the answer back as JSON fields | POST /v1/tasks with output_schema | job |
| React when someone texts me, or build an @twinly bot | webhooks | push |
| Check my key and Mac before a demo | GET /v1/me | now, free |
03Get a key
In the Twinly Mac app: Settings ▸ Developer API ▸ Create a key. Name it after your project and, if you like, set a monthly spending cap. The key is shown once: copy it into your server's environment or a password manager. Twinly keeps only a fingerprint and can never show it again; if you lose it, revoke it and make a new one.
export TWINLY_API_KEY=twk_live_... # never commit this
export API=https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1
Scopes
A new key can do everything; untick what a project does not need in Settings ▸ Developer API. A request outside a key's scopes gets 403 scope_missing, and the message names the scope.
| Scope | Allows |
|---|---|
chat | POST /v1/chat/completions |
computer | POST /v1/computer/next-action |
images | POST /v1/images |
messages.send | POST /v1/messages |
messages.read | GET /v1/messages |
calls | POST /v1/calls |
voice | POST /v1/voice/speak |
tasks | POST /v1/tasks |
bots | @twinly command webhooks (set up in the app) |
GET /v1/me, GET /v1/usage, GET /v1/models and a key's own jobs need no scope.
04Quickstart
The chat endpoint speaks OpenAI's format, so the official openai packages work by changing the base URL.
curl https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1/chat/completions \
-H "Authorization: Bearer $TWINLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "twinly-core", "messages": [{"role": "user", "content": "Write a haiku about Gainesville."}]}'
import os
from openai import OpenAI
client = OpenAI(
base_url="https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1",
api_key=os.environ["TWINLY_API_KEY"],
)
reply = client.chat.completions.create(
model="twinly-core",
messages=[{"role": "user", "content": "Write a haiku about Gainesville."}],
)
print(reply.choices[0].message.content)
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1",
apiKey: process.env.TWINLY_API_KEY,
});
const reply = await client.chat.completions.create({
model: "twinly-core",
messages: [{ role: "user", content: "Write a haiku about Gainesville." }],
});
console.log(reply.choices[0].message.content);
05Chat: POST /v1/chat/completions
| Field | Notes |
|---|---|
model | twinly-core (or omit it) |
messages | 1 to 256 messages; roles system, developer, user, assistant, tool |
| content parts | text, and image_url with a base64 data URL (png, jpeg, webp, gif). Remote image URLs are not fetched. At most 8 images. |
tools, tool_choice | function calling, up to 128 functions |
response_format | text, json_object, json_schema |
temperature, top_p, presence_penalty, frequency_penalty, seed, stop | the usual ranges |
max_tokens / max_completion_tokens | capped at 8192 |
n | at most 4 |
reasoning_effort | none, minimal, low turn the model's thinking off (faster); medium, high turn it on. Default: on for text, off for image turns. |
stream | not supported yet: leave it unset or false |
Other fields are ignored. The response is a standard chat.completion object with choices and usage. Give your HTTP client a timeout of at least 120 seconds when thinking is on.
06Computer use: POST /v1/computer/next-action
Send a goal and a screenshot; get back one action. Your code performs it, takes a new screenshot, and calls again, until the action is done or fail. Twinly does not touch your computer; your code does. Coordinates are on a 0 to 1000 grid over the screenshot (x=0 left, 1000 right; y=0 top, 1000 bottom), so it works at any resolution.
| Request field | |
|---|---|
goal | required. What should be true when the task is finished. Up to 2000 characters. |
screenshot | required. Base64 PNG, JPEG or WebP (a data URL is fine), up to 4 MB. A JPEG about 1280 pixels wide is plenty and much faster. |
previous_screenshot | optional. The screen before your last action, so the model can see whether it worked. |
steps | optional. The actions so far, oldest first, as returned (add result if you know what happened). Up to 50; the last 20 are used. |
elements | optional. On-screen things your code can see, with centers on the same grid: {"id": 3, "role": "button", "label": "Search", "x": 812, "y": 88}. Up to 300. |
{
"object": "computer.action",
"model": "twinly-core",
"coordinates": "0-1000",
"action": {"type": "click", "x": 512, "y": 304, "label": "Search button", "reason": "Open the search box", "commits": false},
"usage": {"prompt_tokens": 1890, "completion_tokens": 41, "total_tokens": 1931}
}
| Action type | Fields |
|---|---|
click, double_click | x, y |
type | text (goes where keyboard focus is) |
key | keys, like return, tab, escape, cmd+l |
scroll | x, y, direction (up, down, left, right), amount (1 to 20) |
wait | seconds (0.2 to 10) |
done | text holds the answer if the goal asked a question |
fail | reason says what blocks it and what a person needs to do |
07Images: POST /v1/images
Describe a picture; get it back as base64. prompt is required (up to 2000 characters); shape is square (1024x1024, the default), wide (1344x768) or tall (768x1344). The response is {"data": [{"b64_json": "...", "mime": "image/jpeg"}]}. A description the image service declines is 422 content_filtered and is not charged.
curl -s "$API/images" \
-H "Authorization: Bearer $TWINLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "a gator in sunglasses on a skateboard, cartoon", "shape": "wide"}'
The image service will not draw a real person from their name. Describe their signature look instead, add “no text or lettering”, and put the names on the picture yourself.
08Mac endpoints: how jobs work
Messages, calls, voice and tasks do not answer from Twinly's servers. Each request becomes a job: the key owner's running Twinly picks it up within seconds, does it the way it does a request typed into the app (their approval settings, Stop and Undo all apply), and reports back. It shows in the owner's task list as “API · <key name> · <kind>”. Because nobody is watching the Mac, three things differ:
- No send preview. A message your job sends goes out when the job runs. A step the owner's approval settings hold still waits for them, and the job shows
needs_approval. - Nothing in the notch. A job's steps and results stay out of the island; the owner sees them in the task list.
- A task never stops to ask a question. It makes the sensible choice, or ends
donewith its question inanswer. Put everything it needs in theprompt.
Set up the Mac first. Texts need Messages signed in plus Full Disk Access and Messages automation; voice needs a voice clone (Twinly ▸ Voice, else voice_not_set_up); calls need Google Voice set up; tasks that use the screen need Screen Recording and Accessibility. Running Set everything up on Twinly's Access screen once avoids a job waiting on a permission card.
Twinly must be open on the owner's Mac. If it has not checked in for 90 seconds, the request fails at once with 409 twinly_offline and nothing is queued. A queued job whose Mac goes away expires 2 minutes later (twinly_offline); a job behind others waits up to 30 minutes, then expires (queue_timeout). Only a job that ends done is charged.
Waiting for the answer
Every job endpoint takes ?wait=<seconds> (0 to 25). If the job finishes in time you get 200 with it; if not, 202 with its current status, and you call GET /v1/jobs/{id}?wait=25, which answers as soon as anything changes. Messages and voice wait 25 seconds by default, so most answer in one call; calls and tasks do not wait by default.
The job object
{
"id": "6f1d2c3b-...",
"object": "job",
"kind": "messages.send",
"status": "done",
"created_at": "2026-10-21T20:00:00.000Z",
"updated_at": "2026-10-21T20:00:02.000Z",
"result": {"to": "+13525550100", "delivered": true, "sent_at": "2026-10-21T20:00:02Z"},
"error": null
}
| Status | Means |
|---|---|
queued | waiting for the owner's Mac to pick it up |
running | the Mac is doing it |
needs_approval | the owner's approval settings held a step; it continues when they decide in the app |
done | finished; result holds the answer |
failed | error.code says why: timeout, voice_not_set_up, voice_starting (retry in a minute), and others |
canceled | canceled through the API before it started, or stopped by the owner |
expired | it never started: twinly_offline or queue_timeout |
A job runs for at most 30 minutes (calls), 15 minutes (tasks) or 2 minutes (everything else), then fails with timeout. Jobs are kept for 7 days, and a key sees only its own.
09Send and read messages
POST /v1/messages
curl -s "$API/messages" \
-H "Authorization: Bearer $TWINLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to": "+13525550100", "text": "Running 10 minutes late!"}'
| Field | |
|---|---|
to or chat | exactly one: to is a phone number, email or contact name; chat is a group chat's id or name |
text | up to 4000 characters (may be empty when you send an attachment) |
attachments | up to 4: an https URL on a public host (the Mac downloads it, up to 25 MB), or a file inline as {"name": "meme.jpg", "mime": "image/jpeg", "b64": "..."} (up to 25 MB) |
Result: {"to": "resolved handle or chat id", "delivered": true, "sent_at": "..."} (delivered is null when Messages cannot tell yet). A message request can be at most 34 MB, enough for one 25 MB file inline; send larger sets as URLs.
GET /v1/messages
Query chat (a chat id or name) or with (a phone number, email or contact), and limit (1 to 50, default 20).
{"messages": [{"from": "me", "name": null, "text": "On my way", "at": "2026-10-21T19:58:00Z"},
{"from": "+13525550100", "name": "Mom", "text": "Drive safe", "at": "2026-10-21T19:59:00Z"}]}10Place a call: POST /v1/calls
Twinly calls the number in the owner's cloned voice, works toward the goal, and hangs up. Fields: to (required, a number or contact name), goal (required, up to 2000 characters), say (optional first line, up to 1000). The answer is 202 with a queued job; wait on it.
curl -s "$API/calls" \
-H "Authorization: Bearer $TWINLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to": "+13525550100", "goal": "Book a table for 2 at 7pm Friday under Sam", "say": "Hi! I would like to book a table."}'
Result: {"minutes": 3, "transcript": [{"who": "them", "text": "..."}, {"who": "twin", "text": "..."}], "outcome": "Booked for 7pm Friday."}
11Speak in the owner's voice: POST /v1/voice/speak
text is up to 600 characters. Result: {"audio_b64": "...", "mime": "audio/mp4", "seconds": 2.9}. Send it as a voice note by passing it to POST /v1/messages as an inline attachment with empty text.
curl -s "$API/voice/speak" \
-H "Authorization: Bearer $TWINLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Happy birthday! Sorry I could not be there."}'12Run a task: POST /v1/tasks
Anything you could ask Twinly in the app: research, the browser, the owner's calendar, files, and the Mac's screen when a job needs it. prompt is up to 4000 characters; a path the task should open is best written in full. Result: {"answer": "...", "files": [{"name": "x.jpg", "mime": "image/jpeg", "b64": "..."}]} (at most 4 files, each up to 8 MB).
import os, requests
API = "https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1"
H = {"Authorization": f"Bearer {os.environ['TWINLY_API_KEY']}"}
job = requests.post(f"{API}/tasks", headers=H, json={"prompt": "Find three flights to Austin under $300 next weekend"}).json()
while job["status"] not in ("done", "failed", "canceled", "expired"):
job = requests.get(f"{API}/jobs/{job['id']}", headers=H, params={"wait": 25}, timeout=40).json()
print(job["result"]["answer"] if job["status"] == "done" else job["error"])
Get data back, not a paragraph
Pass output_schema, a JSON Schema (up to 8 KB), and the result also carries data, parsed and ready to use. The task fills the shape with what it actually found and uses null for anything it could not. If its answer has no JSON that parses, data is null and data_error says so; the full text is still in answer.
schema = {"type": "array", "items": {"type": "object", "properties": {
"airline": {"type": "string"}, "price": {"type": "number"}, "departs": {"type": "string"}}}}
job = requests.post(f"{API}/tasks", headers=H, json={
"prompt": "Three cheapest flights GNV to AUS next Friday", "output_schema": schema}).json()13Check or cancel a job
GET /v1/jobs/{id}?wait=25 returns the job: 200 when it is finished, 202 while it is not. The wait holds the request open until the job finishes or its status changes, so looping on it gives one answer per change with no tight polling. Another key's job id is 404 job_not_found.
POST /v1/jobs/{id}/cancel cancels a job that is still queued (canceling twice is fine). Once the Mac has it, a text may already be sending or a phone ringing, so the API cannot take it back: 409 job_running, and the owner can stop it in Twinly. A finished job is 409 job_finished.
14Webhooks and bots
The owner's Mac can push events to a URL you own, so you do not have to poll, and it can hand @twinly <command> messages to your code and text back what you answer. Set it up in Settings ▸ Developer API ▸ Webhooks:
- Webhook URL: any
https://address, or plainhttp://127.0.0.1:<port>/...for a server on the same Mac (no tunnel needed, the Mac sends the webhook itself). - Show secret, and keep it in your server's environment. Send test event posts a
webhook.test. - Optional: Bot commands (words like
weather), and the chats whose new messages you want asmessage.received.
Every event is {"id": "evt_...", "type": "...", "created_at": "...", "data": {...}}.
| Type | Sent when | data |
|---|---|---|
job.finished | any job ends | the job object (result_omitted: true if over 6 MB; fetch it with GET /v1/jobs/{id}) |
call.ended | a call your key placed ends | job_id, to, minutes, transcript, outcome |
message.received | someone texts a chat you ticked | chat, from, name, text, at |
bot.command | anyone texts @twinly <your command> ... | command, args, chat, from, from_me, text |
webhook.test | you press Send test event | message |
Undelivered events (no answer, or not 2xx) are retried after 1, 4 and 15 seconds; bot.command is not retried, so a reply is never posted twice. Use id to drop a duplicate.
Check the signature
Every POST carries X-Twinly-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 with your secret over <t>.<raw body>. Check it on the raw bytes, before parsing, and refuse a t more than 5 minutes old.
import crypto from "node:crypto";
export function verifySignature(header, rawBody, secret, now = Date.now() / 1000) {
const parts = Object.fromEntries((header || "").split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(now - t) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(t + ".").update(rawBody).digest("hex");
return want.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}
import hmac, hashlib, time
def verify_signature(header, raw_body, secret, now=None):
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
t, v1 = int(parts.get("t", 0)), parts.get("v1", "")
if not t or abs((now or time.time()) - t) > 300:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, v1)
Bots: answer @twinly commands
When a message starts with @twinly and one of your bot commands, Twinly sends bot.command and waits 10 seconds for your answer. Reply 2xx with JSON and Twinly texts it into that chat from the owner's number:
{"reply": "Gainesville: 74°F and sunny", "attachments": ["https://example.com/radar.png"]}
attachmentsare optionalhttpslinks; Twinly downloads and sends them.- Anything other than a
2xxin time is no reply; the owner gets a note on the Mac. - Your command takes priority over Twinly's own answer for that message.
- Anyone in the chat can use your command; people other than the owner get 6 a minute each.
from_meis true when the owner sent it. - Bot commands need a key with the
botsscope on the account.
15Readiness and usage
GET /v1/me needs no scope and costs nothing. Call it first, and again before a demo. mac.online is true while the owner's Twinly checked in within 90 seconds (the same test behind 409 twinly_offline), hint says what to do when it is false, and can lists what this key may call right now.
{
"object": "me",
"key": {"id": "...", "name": "hackathon", "prefix": "twk_live_abc123", "scopes": ["chat", "images", "tasks"]},
"mac": {"online": true, "last_seen": "2026-10-21T22:00:00.000Z", "app_version": "4.1.2", "hint": null},
"can": {"cloud": ["POST /v1/chat/completions", "POST /v1/images"], "mac": ["POST /v1/tasks"]}
}
GET /v1/usage returns this key's month (requests, usage in dollars, its cap) and the account's plan (percent left today and this week, reset times, extra usage). On the free plan it returns prompts_per_day and prompts_left_today. GET /v1/models lists twinly-core.
16Pricing and limits
| What | Price |
|---|---|
| chat, computer next-action | what the model actually used |
| an image | $0.05 |
| sending a message | $0.05 |
| reading messages | $0.02 |
| speaking a line | $0.10 |
| a task | $0.20, plus what the model uses while the task runs |
| a call | $0.30 per started minute, at least one minute |
You are charged only when a job succeeds: its price is set aside when it is created (a call sets aside 5 minutes), charged if it ends done (a call for the minutes it really took), and released otherwise. If the account cannot cover the price, the request is 402 insufficient_usage and nothing is queued.
| Limit | Value |
|---|---|
| Free plan | each API request and each job uses one of the account's 5 free prompts a day, shared with the app; a request that fails before the model answers, or a job that does not succeed, gives its prompt back |
| Paid plans | the plan's daily and weekly limits, shared with the app; extra usage extends them |
| Requests per minute | 30 per key, plus the account's share of engine capacity |
| Requests per day | 2000 per account |
| Keys | 5 live keys per account, 20 new keys a day |
| Monthly cap | optional per key, $5 to $10,000 |
| Request size | 6 MB (34 MB for POST /v1/messages) |
| Output | 8192 tokens per choice, 4 choices |
17Errors
Errors use OpenAI's shape, so the SDKs raise their usual exceptions: {"error": {"message": "...", "type": "...", "code": "invalid_api_key", "param": null}}.
| Status | Code | Meaning | Retry? |
|---|---|---|---|
| 400 | invalid_json, invalid_messages, invalid_parameter, invalid_recipient, invalid_attachments, ... | the request is wrong; param says where | no, fix it |
| 401 | missing_api_key, invalid_api_key | no key, a bad key, or a revoked key | no |
| 402 | free_prompts_used, plan_limit_reached, insufficient_usage, key_cap_reached, ... | out of usage for now, or the key hit its cap | after the reset, or add extra usage |
| 403 | origin_not_allowed | called from a web page | call from a server |
| 403 | scope_missing | the key lacks that scope | turn it on in the app |
| 404 | not_found, job_not_found | unknown endpoint, or not this key's job | no |
| 409 | twinly_offline | the owner's Twinly is not running | yes, once it is open |
| 409 | job_running, job_finished | the job cannot be canceled now | no |
| 413 | request_too_large | over 6 MB (34 MB for a message) | no, shrink it |
| 422 | content_filtered | the image service declined the description | no |
| 429 | rate_limited, fair_share_exceeded | too fast | yes, after Retry-After |
| 502 | invalid_model_output, upstream_timeout, ... | the model's answer was not usable | yes |
| 503 | engine_busy, engine_unavailable, ... | Twinly's side, temporarily | yes, after Retry-After |
| 503 | job_unverified | Twinly could not confirm a job was created | check the job before retrying, so a text is not sent twice |
18Security
- Server side only. A key in a web page, a mobile app or a public repo is a key anyone can use, charged to you. Keep it in an environment variable or a secrets manager.
- One key per project, with only the scopes it needs, and a monthly cap on anything you share with a team.
- Mac endpoints act as the owner. A message comes from their number and a call speaks in their voice. Twinly applies the owner's approval settings to every job; to see each one before it goes out, set an asking mode in the app.
- Rotate and revoke in Settings ▸ Developer API. Revocation takes effect on the key's next request.
- What Twinly stores: only a SHA-256 fingerprint of each key and its first 15 characters. Keys always start with
twk_live_followed by 43 letters and digits, so secret scanners can catch a leaked one.
Codes from events. Got a code at a hackathon or from the Twinly team? Redeem it in Settings ▸ Developer API ▸ Redeem a code (also on Account). Its value lands on the account's extra usage, which every key draws on. Each account can redeem a code once.
19Project ideas
The first six have a ready-to-run starter in twinly-api-examples, with tests and a mock API so you can build before your Mac is set up.
| Idea | Uses |
|---|---|
| A group-chat bot: @twinly roll, @twinly trivia, @twinly weather | bots |
| A nightly digest of a busy group chat, texted to you | messages read, chat, messages |
| Voice postcards: type a line, a friend gets it in your voice | voice, messages |
| A meme machine that texts the picture to the chat | images, messages |
| A phone agent that calls a business and brings back the transcript | calls |
| A command-line “do this for me” for anything Twinly can do | tasks |
| A price or flight watcher that texts you when it drops | tasks with output_schema, messages, on a schedule |
| A QA tester that clicks through your web app and reports what broke | computer next-action |
| Reminders that call you with your day | calls on a schedule |
20When something goes wrong
| You see | It means | Do this |
|---|---|---|
409 twinly_offline | Twinly has not checked in for 90 seconds | open Twinly on the key owner's Mac, keep it awake, confirm with GET /v1/me |
a job stays needs_approval | the owner's approval setting holds that kind of step | approve it in the app, or change the approval mode |
a job sits running and nothing happens | Twinly is waiting on a permission the first time | allow Twinly's card in the app, or run Set everything up once |
voice_not_set_up | no voice clone on that Mac | record one in Twinly ▸ Voice |
402 insufficient_usage | the plan and extra usage cannot cover the job | add extra usage in Account, or redeem a code |
| the bot never answers | no key with the bots scope, a wrong webhook URL, or no reply within 10 s | press Send test event in Settings and answer within 10 s |
| your server rejects every webhook | the signature check runs on parsed JSON | verify on the raw body bytes, before parsing |
| a text shows Not Delivered | the person is not on iMessage, or the number is wrong | check the number; the job's result.delivered says so too |