Remoto Logo
API v1 · version 2026-09-29

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

  1. Create a Remoto business account at /register/agency (or sign in) and set up your business profile under Your Business.
  2. 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.
  3. 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).
  4. 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 /me shows live_access and billing.

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:

Request
curl https://www.proz.com/remoto/api/v1/me \
  -H "Authorization: Bearer rmk_test_..."
Response
{
  "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.

  1. POST /call-requests → status: "waiting" with a join_url. Optionally check GET /availability first and show the price.
  2. Your user opens the link and connects → ringing (webhook call_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).
  3. First interpreter to accept → accepted (webhook call_request.accepted with the interpreter). Your user is put straight into the call room — video or audio, with the interpreter.
  4. The call ends and is settled → completed (webhook call_request.completed, whose data.object.call carries duration, billed seconds and cost).
  5. No answer within ring_timeout_seconds, nobody available, or your user gives up → missed with an end_reason. Create a new request to try again.
Request
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" }
  }'
Response (201)
{
  "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

FieldTypeDescription
language_pair{ source, target } — requiredISO 639 codes, e.g. { "source": "eng", "target": "ara" }. Order does not matter for matching.
call_typevideo | audioDefault video. Audio-only calls use the microphone only.
client_namestringYour end user's name (≤ 80 chars), shown to the interpreter. They can type it on the join page if you leave it out.
notesstringContext for your own records (≤ 2000 chars).
external_referencestringYour id for this call (1–64 chars). Filter on it later.
metadataobjectUp to 20 string key/value pairs, echoed on the object and every webhook.
join_expires_in_minutesintegerHow long the join link stays valid before it is opened: 5–1440, default 60.
ring_timeout_secondsintegerHow long interpreters are rung once your user connects: 30–300, default 120.
test_callbooleanLive 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

FieldTypeDescription
idstringCall request id.
statuswaiting | ringing | accepted | completed | missed | cancelledSee the lifecycle above.
join_urlstring | nullOpen it for your user in a new tab, redirect or in-app browser (not an iframe). Null once the request is closed.
client_secretstring | nullSame credential as join_url, for the Remoto Call script when you embed the call in your own page. Browser-only, per call.
join_expires_atISO 8601After this, an unopened link is cancelled (end_reason join_link_expired).
interpreter{ id, name } | nullSet when an interpreter accepts.
interpreters_rungintegerHow many interpreters were rung.
call_idstring | nullThe connected call. GET /calls/{call_id} for duration, billed seconds and cost.
joined_at / ring_started_at / accepted_at / ended_atISO 8601 | nullTimestamps of each step.
end_reasonstring | nullcompleted, not_answered, no_interpreter_available, client_left, not_connected, ring_failed, cancelled_by_partner, join_link_expired, billing_unavailable, business_inactive.
cancelled_bypartner | system | nullWho cancelled it.
language_pair, call_type, client_name, notes, external_reference, metadataAs 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.

  1. Your server creates the request with POST /call-requests and passes only its client_secret to the browser (never your API key).
  2. Your page loads the script, calls start() to ring, shows your own recording notice and calls acceptRecordingConsent(), then join() when the status becomes accepted.
  3. 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.
HTML
<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>
FieldTypeDescription
RemotoCall.create({ clientSecret, baseUrl? })sessionOne 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? })PromiseConnects media once accepted. Call it again after a page reload to rejoin a live call.
setMicrophone(on) / setCamera(on)PromiseMute and camera controls for your buttons.
hangUp()PromiseEnds the call for everyone; billing stops.
cancel()PromiseWithdraws the request while it is still waiting or ringing.
watch()voidKeeps the status fresh without ringing (scheduled calls).
refresh()Promise<status>Current status on demand.
destroy()PromiseReleases media, timers and listeners.
on(event, cb)unsubscribe fnEvents: 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).

Request
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"
  }'
Response (201)
{
  "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.

FieldTypeDescription
idstringBooking id.
statuspending | confirmed | in_progress | completed | cancelled | expiredpending until an interpreter accepts.
language_pair{ source, target, code }
start_timeISO 8601UTC.
duration_minutesinteger5–480.
call_typeaudio | video
external_referencestring | nullYour own id for the booking (1–64 chars).
metadataobjectYour key/value pairs, echoed back (up to 20 keys).
client_namestring | nullThe end user's name as you sent it (shown to the interpreter).
join_urlstring | nullYour end user's link into the call room — no Remoto account needed. Null once the booking is over.
client_secretstring | nullSame credential as join_url, for embedding the call in your own page with the Remoto Call script.
interpreter{ id, name } | nullSet once confirmed.
originapi | portal | directHow it was booked.
cancelled_by / cancellation_reason / expired_reasonstring | nullWhy 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_approved while 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-Remaining and X-RateLimit-Reset; over the limit you get 429 with Retry-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-Key header (8–128 chars) on POST /call-requests and POST /scheduled-calls; a retried request with the same key returns the original object (header Idempotent-Replayed: true) instead of creating a second one.
  • Join links. A join_url admits 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 (HTTP 400)
{
  "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/meThe key's business, mode and live-access status. Use it as a connectivity check.
GET/availabilityOnline interpreters and price per language pair, right now.
POST/call-requestsRequest an on-demand interpreter now. Returns a join_url for your end user.
GET/call-requestsList your on-demand requests. Filters: status, external_reference.
GET/call-requests/{id}One on-demand request (status, interpreter, call_id).
POST/call-requests/{id}/cancelWithdraw a request that is still waiting or ringing.
POST/scheduled-callsBook a scheduled call. Returns a join_url for your end user.
GET/scheduled-callsList your bookings. Filters: status, from, to, external_reference.
GET/scheduled-calls/{id}One booking.
POST/scheduled-calls/{id}/cancelCancel a booking (late-cancellation policy applies).
GET/callsCall log: connected calls and missed rings, newest first. Includes Phone Line calls.
GET/calls/{id}One call.
GET/usageBilled minutes and cost by language pair, channel and day.
GET/phone-linesYour 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=.

Request
curl "https://www.proz.com/remoto/api/v1/availability?pairs=eng_ara,ara_urd" -H "Authorization: Bearer $REMOTO_KEY"
Response
{
  "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.

Request
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.

FieldTypeDescription
idstringCall id (connected calls) or ring id (missed rings).
channelphone_line | video | audiophone_line = dialled in to the Remoto Phone Line.
statuscompleted | missed | cancelled | in_progresscompleted means billed seconds > 0.
language_pair{ source, target, code }ISO 639-3 codes, e.g. eng / ara.
requested_atISO 8601When ringing started. For operator-fallback calls, the first ring.
answered_atISO 8601 | nullWhen the interpreter picked up.
ended_atISO 8601 | nullWhen the call ended.
answer_secondsinteger | nullPickup time: requested_at → answered_at.
duration_secondsintegerConnected duration.
billed_secondsintegerSeconds billed for the primary interpreter.
operator_fallback_usedbooleanTrue 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 } | nullThe pair the caller originally asked for, when operator fallback was used.
added_interpreters[]arrayInterpreters added into the call: interpreter, language_pair, joined_at, left_at, billed_seconds, cost.
added_interpreter_usedbooleanTrue when at least one interpreter was added.
interpreter{ id, name } | nullThe primary interpreter.
costobject | nullamount, currency, rate_per_minute, breakdown { interpreting, added_interpreters, recording, phone_dialout }, billed_to (business | client), payment_status, agency_earnings.
phone_lineobject | nullaccount_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_reasonstring | nullno_answer, caller_hung_up, ring_failed, no_interpreter_available, portal_inactive, not_answered, not_joined.
ring_secondsintegerMissed rings only: how long it rang.
client{ id, user_name } | nullYour portal client, for white-label portal calls.
reference_codestring | nullCost-centre / case reference captured on the call.
scheduled_call_idstring | nullSet when the call came from a scheduled booking.
pro_bono / testbooleanPro bono and test calls are flagged (test calls are excluded from usage totals).
Example: Phone Line call with operator fallback and an added interpreter
{
  "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.

FieldTypeDescription
call_request.ringingeventYour end user opened the join link and pressed Connect; interpreters are being rung.
call_request.acceptedeventAn interpreter accepted. data.object.interpreter is set; your end user is being put through.
call_request.completedeventThe call ended and was settled. data.object.call is the full call object (billed seconds, cost).
call_request.missedeventNobody answered in time, nobody was available, or the end user left first (see end_reason).
call_request.cancelledeventYou cancelled it, or the join link expired before it was opened.
call.completedeventA call ended and was settled. data.object is a call (with final billed seconds and cost).
call.missedeventA 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.createdeventA booking was created for your business (API, your portal, or on your behalf).
scheduled_call.confirmedeventAn interpreter was confirmed. Fires again if a different interpreter takes over.
scheduled_call.cancelledeventA booking was cancelled, or expired unfilled (status: expired).
invoice.issuedeventYou issued a white-label invoice. data.object is the invoice with its line items.
Delivery (POST to your URL)
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 example evt_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.missed for a ring nobody answered, call.completed for a connected call. Subscribe to whichever fits; most integrations need only call_request.*.
  • Ordering is not guaranteed — e.g. confirmed can arrive before created. Use data.object.status and created, or re-fetch the object.
  • Check livemode: test-key bookings and dashboard tests send livemode: 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.

Node.js (Express)
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);
});
Python (Flask)
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
<?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-requests returns a real join_url. Open it and press Connect: the request goes ringing, is acceptedby "Sandbox Interpreter" about 6 seconds later and completed about 30 seconds after connecting (keep the page open; it drives the simulation) — firing the same call_request.* webhooks (livemode: false). No call room opens and nothing is billed. Test the other paths with POST /call-requests/{id}/cancel and by letting a link expire (join_expires_in_minutes: 5).
  • POST /scheduled-calls validates everything and returns a simulated booking with a scheduled_call.created webhook.
  • 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": true on POST /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].

White-label interpreting for agencies