Build interpreting into your own systems
A REST API and signed webhooks for any platform that needs interpreters: request an interpreter on demand and drop your user straight into the call, book scheduled calls, check live availability, and pull call logs and usage — including Phone Line (OPI) calls.
On-demand, in your app
One API call returns a join link. Your user opens it, Remoto rings matching interpreters, and the first to accept is connected — no Remoto account for your user.
Per-business API keys
Sandbox keys right away; live keys once Remoto approves your business. Hashed at rest, shown once, rotatable, revocable instantly.
Tenant-isolated
A key only ever sees its own business's data. Every request is rate-limited and written to an audit log you can review.
Phone Line (OPI) ready
Answer times, billed seconds, operator fallback, added interpreters and cost on every Phone Line call.
Signed webhooks
HMAC-SHA256 signatures with timestamps, automatic retries with backoff, a delivery log and a test button.
Getting access
- Create a Remoto business account at /register/agency (or sign in) and set up your business profile under Your Business.
- Open Your Business → API & Webhooks and create a test key. Test keys work immediately against the sandbox: every endpoint, real response shapes and real webhooks, but no interpreter is rung and nothing is billed.
- When you are ready for real calls, fill in Request live access on the same page: what you are building, the call types you need, expected volume. Remoto reviews it, usually within one business day, and emails you the decision (it also shows on that page and in
GET /me). - Once approved, the business owner or an admin creates a live key. Live calls are billed to your business: to the card on the owner account by default, or monthly on invoice if Remoto has agreed invoice terms with you.
GET /meshowslive_accessandbilling.
Phone systems and call centres can also send calls straight to Remoto over SIP, with no API calls at all. Write to [email protected] for a SIP trunk; those calls then appear in GET /calls and the call.* webhooks.
Quick start
Call the API with your key as a bearer token:
curl https://www.proz.com/remoto/api/v1/me \
-H "Authorization: Bearer rmk_test_..."{
"object": "api_key_context",
"api_version": "2026-09-29",
"livemode": false,
"key": { "id": "3fJ9kQ2mX8aB", "name": "Staging", "mode": "test" },
"business": { "id": "66f…", "name": "Example Health", "live_access": "requested", "billing": "card", ... }
}Base URL: https://www.proz.com/remoto/api/v1. All requests and responses are JSON over HTTPS. Timestamps are ISO 8601 in UTC. Money is a decimal amount with an ISO 4217 currency. Language codes are ISO 639 (eng, ara, spa, urd …).
On-demand calls (call now)
Your user needs an interpreter now — a patient in a telehealth visit, a customer on your support chat. One request gives you a join_url. Open it for your user in a new tab, a redirect, or your mobile app's in-app browser (it cannot be embedded in an iframe). Nobody is rung until your user presses Connect on that page, so an interpreter never waits in an empty room.
POST /call-requests→status: "waiting"with ajoin_url. Optionally checkGET /availabilityfirst and show the price.- Your user opens the link and connects →
ringing(webhookcall_request.ringing). Remoto rings the online interpreters for the pair, within your interpreter-sourcing setting (your own roster first or only, if you have one). - First interpreter to accept →
accepted(webhookcall_request.acceptedwith the interpreter). Your user is put straight into the call room — video or audio, with the interpreter. - The call ends and is settled →
completed(webhookcall_request.completed, whosedata.object.callcarries duration, billed seconds and cost). - No answer within
ring_timeout_seconds, nobody available, or your user gives up →missedwith anend_reason. Create a new request to try again.
curl https://www.proz.com/remoto/api/v1/call-requests \
-H "Authorization: Bearer $REMOTO_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: visit-5521-interp" \
-d '{
"language_pair": { "source": "eng", "target": "spa" },
"call_type": "video",
"client_name": "Maria Lopez",
"external_reference": "visit-5521",
"metadata": { "clinic": "north", "visit_id": "5521" }
}'{
"id": "6710c2…",
"object": "call_request",
"livemode": true,
"status": "waiting",
"language_pair": { "source": "eng", "target": "spa", "code": "eng_spa" },
"call_type": "video",
"client_name": "Maria Lopez",
"external_reference": "visit-5521",
"metadata": { "clinic": "north", "visit_id": "5521" },
"join_url": "https://proz.com/remoto/connect/Xk3…",
"join_expires_at": "2026-10-13T15:02:11.000Z",
"ring_timeout_seconds": 120,
"interpreter": null,
"call_id": null,
...
}Parameters
| Field | Type | Description |
|---|---|---|
| language_pair | { source, target } — required | ISO 639 codes, e.g. { "source": "eng", "target": "ara" }. Order does not matter for matching. |
| call_type | video | audio | Default video. Audio-only calls use the microphone only. |
| client_name | string | Your end user's name (≤ 80 chars), shown to the interpreter. They can type it on the join page if you leave it out. |
| notes | string | Context for your own records (≤ 2000 chars). |
| external_reference | string | Your id for this call (1–64 chars). Filter on it later. |
| metadata | object | Up to 20 string key/value pairs, echoed on the object and every webhook. |
| join_expires_in_minutes | integer | How long the join link stays valid before it is opened: 5–1440, default 60. |
| ring_timeout_seconds | integer | How long interpreters are rung once your user connects: 30–300, default 120. |
| test_call | boolean | Live keys only. Spend your business's test-call allowance instead of billing: free, rings only Remoto's designated test interpreters. Request an allowance on the API & Webhooks page; 402 no_test_allowance without one. |
The call request object
| Field | Type | Description |
|---|---|---|
| id | string | Call request id. |
| status | waiting | ringing | accepted | completed | missed | cancelled | See the lifecycle above. |
| join_url | string | null | Open it for your user in a new tab, redirect or in-app browser (not an iframe). Null once the request is closed. |
| client_secret | string | null | Same credential as join_url, for the Remoto Call script when you embed the call in your own page. Browser-only, per call. |
| join_expires_at | ISO 8601 | After this, an unopened link is cancelled (end_reason join_link_expired). |
| interpreter | { id, name } | null | Set when an interpreter accepts. |
| interpreters_rung | integer | How many interpreters were rung. |
| call_id | string | null | The connected call. GET /calls/{call_id} for duration, billed seconds and cost. |
| joined_at / ring_started_at / accepted_at / ended_at | ISO 8601 | null | Timestamps of each step. |
| end_reason | string | null | completed, not_answered, no_interpreter_available, client_left, not_connected, ring_failed, cancelled_by_partner, join_link_expired, billing_unavailable, business_inactive. |
| cancelled_by | partner | system | null | Who cancelled it. |
| language_pair, call_type, client_name, notes, external_reference, metadata | As you sent them. |
Track progress with webhooks (recommended) or by polling GET /call-requests/{id} every few seconds. POST /call-requests/{id}/cancel withdraws a request that is still waiting or ringing; once it is accepted the call is happening and you get 409. The join_url is the credential for that one call — send it only to your user. A user who drops can reopen it to rejoin while the call is live.
Billing. Billing starts when the interpreter and your user are both connected; ring time is free. The rate is your wholesale or business rate card for the pair if Remoto has set one, otherwise the Remoto platform rate — the same rate GET /availability returns.
Embed the call in your own page (Remoto Call script)
Prefer your own interface over the hosted join page? Load the Remoto Call script and draw the call yourself: your buttons, your layout, your brand. Remoto supplies the interpreter, the media connection and the minute tracking. Interpreters still answer in Remoto as usual.
- Your server creates the request with
POST /call-requestsand passes only itsclient_secretto the browser (never your API key). - Your page loads the script, calls
start()to ring, shows your own recording notice and callsacceptRecordingConsent(), thenjoin()when the status becomesaccepted. - The script plays the interpreter's audio, renders video into the
<video>elements you pass, reconnects after network drops, and reports presence to Remoto. Billing runs only while both your user and the interpreter are in the call; Remoto also checks the room itself, so billing never depends on your page alone.
<video id="interpreter" autoplay playsinline></video>
<video id="me" autoplay playsinline muted></video>
<button id="connect">Get an interpreter</button>
<button id="hangup">End call</button>
<script src="https://www.proz.com/remoto/sdk/remoto-call.js"></script>
<script>
// clientSecret comes from YOUR server: POST /v1/call-requests → client_secret
const call = RemotoCall.create({ clientSecret: CLIENT_SECRET });
call.on("status", async (s) => {
showStatus(s.status); // waiting | ringing | accepted | completed | missed | cancelled
if (s.status === "accepted") {
await call.join({
remoteVideo: document.getElementById("interpreter"),
localVideo: document.getElementById("me"),
});
}
});
call.on("interpreter", (i) => showInterpreter(i.name));
call.on("connection", (c) => showConnection(c)); // joining | joined | reconnecting | left
call.on("ended", (e) => showEnded(e.reason));
call.on("error", (err) => console.warn(err.code, err.message));
document.getElementById("connect").onclick = async () => {
if (!confirm("This session may be recorded for quality and compliance. Continue?")) return;
await call.acceptRecordingConsent();
await call.start({ name: "Maria Lopez" });
};
document.getElementById("hangup").onclick = () => call.hangUp();
</script>| Field | Type | Description |
|---|---|---|
| RemotoCall.create({ clientSecret, baseUrl? }) | session | One session per call request. baseUrl defaults to where the script was loaded from. |
| start({ name? }) | Promise<status> | Rings interpreters. Safe to call again. |
| acceptRecordingConsent() | Promise<status> | Required before join(): show your own notice first. Remoto refuses media credentials without it. |
| join({ remoteVideo?, localVideo?, cameraOff?, microphoneOff? }) | Promise | Connects media once accepted. Call it again after a page reload to rejoin a live call. |
| setMicrophone(on) / setCamera(on) | Promise | Mute and camera controls for your buttons. |
| hangUp() | Promise | Ends the call for everyone; billing stops. |
| cancel() | Promise | Withdraws the request while it is still waiting or ringing. |
| watch() | void | Keeps the status fresh without ringing (scheduled calls). |
| refresh() | Promise<status> | Current status on demand. |
| destroy() | Promise | Releases media, timers and listeners. |
| on(event, cb) | unsubscribe fn | Events: status, interpreter, connection, participants, ended, error. |
Scheduled calls embed the same way: use the booking's client_secret (from POST /scheduled-calls). There is nothing to ring, so skip start() and call watch() to keep the status fresh. The status is waiting until an interpreter is confirmed and accepted after. joinable turns true 30 minutes before startsAt; join any time after that. Before then, join() fails with too_early.
Errors carry a code: consent_required, not_accepted, too_early, call_closed, sandbox (test-key requests have no media room: the status lifecycle and webhooks are simulated, but join() is refused), not_found.
The script is about 125 KB gzipped and works in current Chrome, Edge, Firefox and Safari (desktop and mobile). Pages must be served over HTTPS for camera and microphone access. Calls placed this way have audio/video only. Recording settings follow your account; adding a second interpreter, dialling a phone number in and live captions are available in the hosted join page.
Scheduled calls
For appointments: book a time, Remoto finds and confirms an interpreter, and your user joins at the time through the booking's join_url (no Remoto account). Nobody is picked up front: Remoto invites matching interpreters (within your sourcing setting) and the first to accept confirms it — you get scheduled_call.confirmed. start_time must be at least 15 minutes ahead. Live bookings need invoice terms on your business or a verified payment method on the owner account (otherwise 402).
curl https://www.proz.com/remoto/api/v1/scheduled-calls \
-H "Authorization: Bearer $REMOTO_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: booking-8841" \
-d '{
"language_pair": { "source": "eng", "target": "ara" },
"start_time": "2026-10-20T09:00:00Z",
"duration_minutes": 30,
"call_type": "video",
"client_name": "Ahmed Saleh",
"notes": "Cardiology follow-up",
"external_reference": "booking-8841"
}'{
"id": "66fa0c…",
"object": "scheduled_call",
"livemode": true,
"status": "pending",
"language_pair": { "source": "eng", "target": "ara", "code": "eng_ara" },
"start_time": "2026-10-20T09:00:00.000Z",
"duration_minutes": 30,
"call_type": "video",
"client_name": "Ahmed Saleh",
"join_url": "https://proz.com/remoto/join/9f3c…",
"external_reference": "booking-8841",
"origin": "api",
"interpreter": null,
...
}Parameters: language_pair (required), start_time (required), duration_minutes (5–480, default 60), call_type (audio | video, default audio), notes (shown to the interpreter, ≤ 2000 chars), client_name, external_reference, metadata, and client_email with notify_client: true if you want Remoto to email your user the confirmation and reminders (off by default — you own the conversation).
POST /scheduled-calls/{id}/cancel with an optional reason cancels a booking that has not started. If an interpreter is already confirmed, the late-cancellation policy may charge a fee; the response returns cancellation_fee.
| Field | Type | Description |
|---|---|---|
| id | string | Booking id. |
| status | pending | confirmed | in_progress | completed | cancelled | expired | pending until an interpreter accepts. |
| language_pair | { source, target, code } | |
| start_time | ISO 8601 | UTC. |
| duration_minutes | integer | 5–480. |
| call_type | audio | video | |
| external_reference | string | null | Your own id for the booking (1–64 chars). |
| metadata | object | Your key/value pairs, echoed back (up to 20 keys). |
| client_name | string | null | The end user's name as you sent it (shown to the interpreter). |
| join_url | string | null | Your end user's link into the call room — no Remoto account needed. Null once the booking is over. |
| client_secret | string | null | Same credential as join_url, for embedding the call in your own page with the Remoto Call script. |
| interpreter | { id, name } | null | Set once confirmed. |
| origin | api | portal | direct | How it was booked. |
| cancelled_by / cancellation_reason / expired_reason | string | null | Why it ended without a call. |
Authentication & security
Send your key in the Authorization: Bearer header (or X-Api-Key). Keys look like rmk_live_… or rmk_test_…. Treat them like passwords: server-side only, never in a browser or mobile app.
- Hashed at rest, shown once. We store only a SHA-256 hash of each key. If you lose one, rotate or create a new key.
- Rotation with grace. Rotating issues a replacement immediately; the old key keeps working for 24 hours so you can redeploy without downtime. Revoking is immediate.
- Who can create keys. Live keys need approved live access, and can be created or rotated only by the business owner or an admin. Team members with settings access can create test keys. If Remoto suspends live access, live keys stop with
403 live_access_not_approvedwhile test keys keep working. - Test vs live. Test keys read your real data but never ring or book anyone: see Testing. Live keys place real calls with real interpreters.
- Tenant isolation. The business is taken from the key alone — no endpoint accepts a business id — so a key can never read or write another business's data.
- Rate limits. 120 requests per minute per key, counted separately in each Remoto data centre, so treat 120 as the budget even if you occasionally get more through. Every response carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset; over the limit you get429withRetry-After. Repeated failed authentication from one IP is also throttled. - Audit log. Every request made with your keys — including rejected ones — is logged with time, key id, method, path, status and IP, and is visible in the dashboard for 90 days.
- Idempotency. Send an
Idempotency-Keyheader (8–128 chars) onPOST /call-requestsandPOST /scheduled-calls; a retried request with the same key returns the original object (headerIdempotent-Replayed: true) instead of creating a second one. - Join links. A
join_urladmits whoever holds it to that one call. Hand it only to your user, over HTTPS, and do not log it in analytics.
Errors use one shape, with a request id you can quote to support:
{
"error": {
"type": "invalid_request_error",
"code": "invalid_start_time",
"message": "start_time must be an ISO 8601 date-time",
"param": "start_time"
}
}
// Response header: Remoto-Request-Id: req_3b1c…Types: authentication_error (401), permission_error (403), invalid_request_error (400/402), not_found (404), rate_limit_error (429), api_error (500). A language code Remoto does not support is a 400 with code: "unknown_language".
Endpoints
| GET | /me | The key's business, mode and live-access status. Use it as a connectivity check. |
| GET | /availability | Online interpreters and price per language pair, right now. |
| POST | /call-requests | Request an on-demand interpreter now. Returns a join_url for your end user. |
| GET | /call-requests | List your on-demand requests. Filters: status, external_reference. |
| GET | /call-requests/{id} | One on-demand request (status, interpreter, call_id). |
| POST | /call-requests/{id}/cancel | Withdraw a request that is still waiting or ringing. |
| POST | /scheduled-calls | Book a scheduled call. Returns a join_url for your end user. |
| GET | /scheduled-calls | List your bookings. Filters: status, from, to, external_reference. |
| GET | /scheduled-calls/{id} | One booking. |
| POST | /scheduled-calls/{id}/cancel | Cancel a booking (late-cancellation policy applies). |
| GET | /calls | Call log: connected calls and missed rings, newest first. Includes Phone Line calls. |
| GET | /calls/{id} | One call. |
| GET | /usage | Billed minutes and cost by language pair, channel and day. |
| GET | /phone-lines | Your Phone Line (OPI) accounts, read-only. |
Availability
Who could take a call right now, counted exactly the way a call placed now would ring: your interpreter sourcing setting applies (roster-only businesses see only their live roster unless coverage fallback is on), and pass client_idto apply a portal client's team rules. The rate is your wholesale rate group when your account has wholesale pricing (with its billing increment and minimum), else your client rate card for the pair, else the Remoto platform rate. Pass call_type=opi (default) or vri for the phone or video price. Up to 20 pairs per request with pairs=.
curl "https://www.proz.com/remoto/api/v1/availability?pairs=eng_ara,ara_urd" -H "Authorization: Bearer $REMOTO_KEY"{
"object": "list",
"data": [
{
"object": "availability",
"language_pair": { "source": "eng", "target": "ara", "code": "eng_ara", "label": "English ↔ Arabic" },
"available_now": true,
"interpreters_online": 7,
"roster_interpreters_online": 2,
"interpreters_total": 412,
"sourcing": "roster_first",
"rate": { "amount": 1.5, "currency": "USD", "unit": "minute", "source": "business_rate_card" },
"checked_at": "2026-09-29T14:02:11.000Z"
}
]
}Call logs and usage
GET /calls returns connected calls and missed rings together, newest first. Filters: from, to (default: last 30 days, max 366), status (all, completed, missed, cancelled), channel (phone_line, video, audio), limit (≤ 100). Page with starting_after=<next_cursor> while has_more is true.
curl "https://www.proz.com/remoto/api/v1/calls?channel=phone_line&from=2026-09-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer $REMOTO_KEY"GET /usage?from=&to= totals completed calls (billed seconds and minutes, cost per currency) by language pair, channel and UTC day, plus missed counts. Test calls are excluded.
GET /phone-lines lists your Phone Line accounts: account number, dial-in number, speed-dial pairs, operator-fallback pair, and the SIP trunks that route your PBX into the line (SIP URI, auth mode, allowed IPs, digest username, per-pair extensions). IVR PINs and SIP passwords are never returned by the API. Send your own call id in the X-Remoto-Ref SIP header and it comes back as phone_line.partner_ref on the call and its webhooks, so you can reconcile against your CDRs.
The call object
Phone Line calls carry everything a telecom partner needs to reconcile: when ringing started, when the interpreter picked up, pickup seconds, billed seconds, whether the operator fallback or an added interpreter was used, and the cost.
| Field | Type | Description |
|---|---|---|
| id | string | Call id (connected calls) or ring id (missed rings). |
| channel | phone_line | video | audio | phone_line = dialled in to the Remoto Phone Line. |
| status | completed | missed | cancelled | in_progress | completed means billed seconds > 0. |
| language_pair | { source, target, code } | ISO 639-3 codes, e.g. eng / ara. |
| requested_at | ISO 8601 | When ringing started. For operator-fallback calls, the first ring. |
| answered_at | ISO 8601 | null | When the interpreter picked up. |
| ended_at | ISO 8601 | null | When the call ended. |
| answer_seconds | integer | null | Pickup time: requested_at → answered_at. |
| duration_seconds | integer | Connected duration. |
| billed_seconds | integer | Seconds billed for the primary interpreter. |
| operator_fallback_used | boolean | True when nobody could take the caller's pair and the line's operator pair (e.g. English–Arabic) answered instead. |
| requested_language_pair | { source, target, code } | null | The pair the caller originally asked for, when operator fallback was used. |
| added_interpreters[] | array | Interpreters added into the call: interpreter, language_pair, joined_at, left_at, billed_seconds, cost. |
| added_interpreter_used | boolean | True when at least one interpreter was added. |
| interpreter | { id, name } | null | The primary interpreter. |
| cost | object | null | amount, currency, rate_per_minute, breakdown { interpreting, added_interpreters, recording, phone_dialout }, billed_to (business | client), payment_status, agency_earnings. |
| phone_line | object | null | account_id, account_number, caller_number_masked, sip_trunk_id and partner_ref (your own call reference, sent as the X-Remoto-Ref SIP header) for calls over your SIP trunk, leg { answered_at, ended_at, billable_seconds, hangup_cause }. |
| missed_reason | string | null | no_answer, caller_hung_up, ring_failed, no_interpreter_available, portal_inactive, not_answered, not_joined. |
| ring_seconds | integer | Missed rings only: how long it rang. |
| client | { id, user_name } | null | Your portal client, for white-label portal calls. |
| reference_code | string | null | Cost-centre / case reference captured on the call. |
| scheduled_call_id | string | null | Set when the call came from a scheduled booking. |
| pro_bono / test | boolean | Pro bono and test calls are flagged (test calls are excluded from usage totals). |
{
"id": "call-5c1e…",
"object": "call",
"channel": "phone_line",
"status": "completed",
"language_pair": { "source": "eng", "target": "ara", "code": "eng_ara" },
"requested_at": "2026-09-29T08:00:03.000Z",
"answered_at": "2026-09-29T08:00:41.000Z",
"ended_at": "2026-09-29T08:14:12.000Z",
"answer_seconds": 38,
"duration_seconds": 811,
"billed_seconds": 811,
"operator_fallback_used": true,
"requested_language_pair": { "source": "ara", "target": "urd", "code": "ara_urd" },
"added_interpreters": [
{ "interpreter": { "id": "5512", "name": "A. Khan" },
"language_pair": { "source": "ara", "target": "urd", "code": "ara_urd" },
"billed_seconds": 640, "cost": 12.8 }
],
"added_interpreter_used": true,
"cost": {
"amount": 33.08, "currency": "USD", "rate_per_minute": 1.5,
"breakdown": { "interpreting": 20.28, "added_interpreters": 12.8, "recording": 0, "phone_dialout": 0 },
"billed_to": "business", "payment_status": "succeeded", "agency_earnings": null
},
"phone_line": { "account_number": "4821", "caller_number_masked": "+9*******4512",
"sip_trunk_id": "66fb…", "partner_ref": "3cx-call-000123",
"leg": { "answered_at": "…", "ended_at": "…", "billable_seconds": 809, "hangup_cause": "normal_clearing" } },
"missed_reason": null
}Webhooks
Add endpoints under API & Webhooks and pick the events you want (none = all). Each endpoint gets its own signing secret (whsec_…), shown once. Use Send test to fire a signed sample of any event at your endpoint: its data.object has exactly the shape of a real event (fake ids, livemode: false). A new endpoint starts receiving live events within a minute.
| Field | Type | Description |
|---|---|---|
| call_request.ringing | event | Your end user opened the join link and pressed Connect; interpreters are being rung. |
| call_request.accepted | event | An interpreter accepted. data.object.interpreter is set; your end user is being put through. |
| call_request.completed | event | The call ended and was settled. data.object.call is the full call object (billed seconds, cost). |
| call_request.missed | event | Nobody answered in time, nobody was available, or the end user left first (see end_reason). |
| call_request.cancelled | event | You cancelled it, or the join link expired before it was opened. |
| call.completed | event | A call ended and was settled. data.object is a call (with final billed seconds and cost). |
| call.missed | event | A ring ended unanswered, or a Phone Line call found nobody to ring. Re-routes to the operator pair are not reported as missed. |
| scheduled_call.created | event | A booking was created for your business (API, your portal, or on your behalf). |
| scheduled_call.confirmed | event | An interpreter was confirmed. Fires again if a different interpreter takes over. |
| scheduled_call.cancelled | event | A booking was cancelled, or expired unfilled (status: expired). |
| invoice.issued | event | You issued a white-label invoice. data.object is the invoice with its line items. |
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Remoto-Webhooks/2026-09-29
Remoto-Signature: t=1790690000,v1=5f2b…c9
Remoto-Event-Id: evt_call_completed_call_5c1e…
Remoto-Event-Type: call.completed
Remoto-Delivery-Id: 66fa…
{
"id": "evt_call_completed_call_5c1e…",
"object": "event",
"type": "call.completed",
"api_version": "2026-09-29",
"created": "2026-09-29T08:14:20.000Z",
"livemode": true,
"business_id": "66f…",
"data": { "object": { "id": "call-5c1e…", "object": "call", ... } }
}- Respond 2xx quickly, then do the work asynchronously. An attempt fails if your server sends nothing for 10 seconds or has not finished responding within 15 seconds. Redirects are not followed.
- Retries: a failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 8 h and 24 h (7 attempts), then marked failed. You can resend any delivery from the dashboard.
- Deduplicate on
id. An event is delivered at least once; the same transition always has the same event id, built from the event type and the object id with anything other than letters, digits and_turned into_(for exampleevt_call_completed_call_5c1e…,evt_sc_confirmed_66fa…_1234,evt_cr_accepted_6710…). Dashboard test events get a random id. - An on-demand request fires its
call_request.*events and also the generic call events —call.missedfor a ring nobody answered,call.completedfor a connected call. Subscribe to whichever fits; most integrations need onlycall_request.*. - Ordering is not guaranteed — e.g.
confirmedcan arrive beforecreated. Usedata.object.statusandcreated, or re-fetch the object. - Check
livemode: test-key bookings and dashboard tests sendlivemode: false. - Webhook URLs must be public
https://addresses.
Verifying signatures
Remoto-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of <t>.<raw request body> keyed with your endpoint secret. Compute it over the raw bytes (before JSON parsing), compare in constant time, and reject timestamps more than 5 minutes old to block replays.
const crypto = require("crypto");
const express = require("express");
const app = express();
app.post("/remoto/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Remoto-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).send("stale");
const expected = crypto
.createHmac("sha256", process.env.REMOTO_WEBHOOK_SECRET)
.update(`${t}.${req.body.toString("utf8")}`)
.digest("hex");
const given = Buffer.from(parts.v1 || "", "hex");
const ok = given.length === 32 && crypto.timingSafeEqual(Buffer.from(expected, "hex"), given);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// …queue event.id for processing, dedupe on event.id…
res.sendStatus(200);
});import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["REMOTO_WEBHOOK_SECRET"].encode()
@app.post("/remoto/webhooks")
def remoto_webhook():
raw = request.get_data() # raw bytes
parts = dict(p.split("=", 1) for p in request.headers.get("Remoto-Signature", "").split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > 300:
abort(400)
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
event = request.get_json()
# ...dedupe on event["id"], process asynchronously...
return "", 200<?php
$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_REMOTO_SIGNATURE'] ?? ''), $parts);
$t = (int)($parts['t'] ?? 0);
if (abs(time() - $t) > 300) { http_response_code(400); exit; }
$expected = hash_hmac('sha256', $t . '.' . $raw, getenv('REMOTO_WEBHOOK_SECRET'));
if (!hash_equals($expected, $parts['v1'] ?? '')) { http_response_code(400); exit; }
$event = json_decode($raw, true);
// ...dedupe on $event['id']...
http_response_code(200);Testing (sandbox)
Everything can be built and tested with a test key before Remoto approves live access:
POST /call-requestsreturns a realjoin_url. Open it and press Connect: the request goesringing, isacceptedby "Sandbox Interpreter" about 6 seconds later andcompletedabout 30 seconds after connecting (keep the page open; it drives the simulation) — firing the samecall_request.*webhooks (livemode: false). No call room opens and nothing is billed. Test the other paths withPOST /call-requests/{id}/canceland by letting a link expire (join_expires_in_minutes: 5).POST /scheduled-callsvalidates everything and returns a simulated booking with ascheduled_call.createdwebhook.- Send test on a webhook endpoint fires a signed sample of any event type, including every
call_request.*event. - Real calls, free: request a test-call allowanceon the API & Webhooks page. Once Remoto grants it (you get an email), send
"test_call": trueonPOST /call-requestswith your live key: the call rings only Remoto's designated test interpreters, runs the real media and webhooks, and nothing is billed. Each call uses one test call from the allowance.
Versioning & support
This is API version 2026-09-29, returned on /me and in every event. We add fields and event types without notice; we never remove or rename fields within a version. Build your parsers to ignore unknown fields.
Questions or a new integration? Quote the Remoto-Request-Id header and write to [email protected].