Appearance
Scenario: a phone number for every user
The integration path for products that give each of their users their own phone number: an eSIM app, a virtual-number or second-line app, a "business phone in your pocket". Your carrier gives you a block of numbers (DIDs) on a SIP trunk; you hand one to each user; people call that number and it rings the user's app; the user calls out from it and the other side sees that number.
This page walks the whole path in order: what to ask for, what to set up once, what to do when a user signs up, what the app does, and how to run it day to day. It builds on the integration guide; the field-by-field detail is in the Management API, events and agent protocol references.
Steps at a glance
| Step | Required? | API |
|---|---|---|
| Before you start: DIDs and carrier details | Required | — |
| The operator: consumer and trunk | Required | from the Calloop team |
| Your tenant | Required | PUT /v1/tenants/{t} |
| Token endpoint | Required | your backend |
| Webhook | Required (missed calls, history) | PUT /v1/webhook |
| Push credentials | Required (phones ring in the background) | PUT /v1/push-credentials/* |
| Caller names | Optional | lookup_url |
| Your number inventory | Recommended | PUT …/channels/{ref}, GET …/channels?unowned=true |
| When a user signs up | Required | PUT …/agents/{ref}, PUT …/channels/{ref}, push tokens |
| The app | Required | Dart SDK, POST …/calls for outbound |
| Running it day to day | As needed | do-not-disturb, line moves, DELETE …/agents/{ref} |
| Test before launch | Required | — |
1. How it fits together
Caller ──► Carrier ──SIP trunk──► CALLOOP ──socket + WebRTC──► User's app
│ ▲
webhook (call.ended)│ │ REST API (API key)
▼ │
Your backend| In Calloop | In your product |
|---|---|
| One tenant | Your whole product |
| One agent per user | A user (their agent ref is your user id) |
| One SIP line (channel) per number, with that user as its owner | A number assigned to a user |
| A line with no owner | A number in stock, not yet assigned |
| The SIP trunk | Your carrier connection (set up by the Calloop operator) |
A call to a number rings only its owner, on every device they are signed in on, whatever their availability. Only the owner can call out from their number. Users never see each other's calls. A one-to-one call on such a line takes Calloop's direct media path, the cheapest way Calloop carries audio.
Who does what
| Who | Does |
|---|---|
| Your carrier | Routes your DIDs to Calloop; carries outbound calls with your DIDs as caller ID |
| The Calloop operator | Creates your consumer and your SIP trunk |
| Your backend | Creates the tenant, users and lines; signs agent tokens; receives events; places outbound calls |
| Your app | Rings, answers, calls out, shows the call screen (with the Calloop SDK) |
2. Before you start
From your carrier:
- The list of DIDs you bought.
- Their signalling and media IP addresses, and whether they send numbers in international format (
+44…) or national format (07…). - Ask them to: route all your DIDs to Calloop's SIP address (from the operator); enable outbound calls on the trunk; and allow your DIDs as caller ID. Calloop presents the user's number as the caller, without the
+.
From the Calloop operator (step 3.1 below):
- Your API key, your signing key (
kid+ secret) and, if they set a webhook or lookup URL for you, your webhook secret (otherwise your firstPUT /v1/webhookwith a URL returns it, once). Shown once: keep them in your secrets manager. - Calloop's base URL and SIP address.
- Your trunk id (also listed by
GET /v1/sip-trunks).
3. Set up once
3.1 The operator: your consumer and your trunk
Required. The operator creates your consumer. Your carrier's trunk you can add yourself (POST /v1/sip-trunks, or the dashboard's SIP trunks: host, the carrier's public signalling addresses, number format, credentials), then submit it for the operator's approval; point the carrier at its ingressHost. Or send the operator your carrier details and they add it for you: its host, the carrier's signalling addresses, its number format (country code and national prefix, if it sends national numbers), any digest credentials for outbound, and its media addresses so Calloop lets its audio in.
Either way the trunk is yours: no other consumer can put lines on it, and GET /v1/sip-trunks shows you its settings and its trunk id.
3.2 Your tenant
Required. One tenant for the whole product, set so that users stay private and calls stay on the direct path:
http
PUT /v1/tenants/myapp
Authorization: Bearer ck_yourapp_…
{ "name": "Your App",
"settings": { "team_view": false, "monitoring": { "modes": ["listen"] } } }team_view: false: a user only ever sees their own calls.monitoring.modes: ["listen"]: your staff may listen to a call (for support), never speak in it. Allowing whisper or barge would move every call off the direct path.- Recording (
recording_enabled: trueon a line) keeps the direct path: copies of both sides are recorded, the call's own audio is untouched.
3.3 Your backend
Token endpoint (required). Your app connects to Calloop with a short-lived token your backend signs for the logged-in user. Add an endpoint such as GET /api/calloop-token:
js
// Node (jsonwebtoken)
const token = jwt.sign(
{ iss: 'yourapp', tnt: 'myapp', sub: user.id, name: user.name },
process.env.CALLOOP_SIGNING_SECRET,
{ algorithm: 'HS256', keyid: process.env.CALLOOP_SIGNING_KID, expiresIn: '10m' },
);Return a token only for the logged-in user, never for anyone else, and never put the signing secret in the app (integration guide → Sign agent tokens).
Webhook (required). Register your endpoint, at least for call.ended:
http
PUT /v1/webhook
{ "url": "https://api.example.com/calloop/events",
"event_types": ["call.created", "call.answered", "call.ended"] }If the operator sent you no webhook secret, this first PUT returns it ("secret": "…"), once: store it.
Verify the signature of every request, answer 2xx quickly, and de-duplicate on event_id (events → Verifying requests). call.ended carries the whole call: the line (channelRef), the user (agentRef), the other party (customer.address), how it ended (terminationReason) and how long it lasted (durations.call, in seconds). It is what you build call history, missed-call notifications and billing from.
Push credentials (required). Upload them once, so Calloop can wake your users' phones for a call:
http
PUT /v1/push-credentials/fcm
{ "service_account": { …your Firebase service account JSON… } }
PUT /v1/push-credentials/apns
{ "key_p8": "-----BEGIN PRIVATE KEY-----…", "key_id": "…",
"team_id": "…", "bundle_id": "com.yourapp", "production": true }production defaults to false (Apple's sandbox, for development builds); release and TestFlight builds need true.
Caller names (optional). Add a lookup_url to the same PUT /v1/webhook (that call replaces both URLs, so always send them together) and Calloop asks your backend about every incoming caller before ringing (within 1.5 s): reply with a display name, for example from the user's contacts, or ask Calloop to reject a blocked caller (events → Lookup hook).
4. Your number inventory
Recommended. Create a line for every number you hold, with no owner, as soon as you get it. Calloop then knows the number, and a call to a number nobody has yet is rejected cleanly.
http
PUT /v1/tenants/myapp/channels/did-442079460456
{ "type": "SIP", "address": "+442079460456", "sip_trunk_id": 7 }Use a channel ref you can derive from the number (did-<digits>), so you never need to store Calloop's ids. The address is always E.164 (+ and the country code).
Find numbers later with the line list:
| You want | Call |
|---|---|
| Numbers not yet assigned | GET /v1/tenants/myapp/channels?type=SIP&unowned=true |
| A user's number(s) | GET /v1/tenants/myapp/channels?owner_agent_ref=user-7 |
| Who has a number | GET /v1/tenants/myapp/channels?address=%2B442079460456 |
| Everything, a page at a time | …/channels?limit=200, then &after_id=<nextAfterId> until nextAfterId is null |
Your own database stays the source of truth for which user has which number; Calloop only needs to know the current owner.
5. When a user signs up
Required. Two calls from your backend: the user, then their number.
http
PUT /v1/tenants/myapp/agents/user-7
{ "name": "Jane Doe" }
PUT /v1/tenants/myapp/channels/did-442079460456
{ "type": "SIP", "address": "+442079460456", "sip_trunk_id": 7,
"owner_agent_ref": "user-7", "ring_timeout_seconds": 30 }ring_timeout_seconds(5–60, default 30): how long the number rings before the call counts as missed.- A
PUTreplaces the whole line, so always sendtype,addressandsip_trunk_idwith it. - A user may own more than one number: give each line the same
owner_agent_ref.
When the app registers a device for push, your backend passes the token on:
http
PUT /v1/tenants/myapp/agents/user-7/push-tokens/<deviceId>
{ "platform": "ANDROID", "provider": "FCM", "token": "<fcm token>" }On iOS register two tokens for the same device: APNS_VOIP (the PushKit token, for calls) and FCM (for notifications). On logout, DELETE …/push-tokens/<deviceId>. Use the same deviceId the app connects with.
6. The app
Required. Built on the Flutter (Dart) SDK; a web app uses the JavaScript SDK the same way.
Connect when the user is signed in. There is no "go available" step: the number rings whatever the user's availability.
dart
final agent = await CalloopAgent.connect(
url: 'https://api.calloop.net',
// Stable per installation, and the same id you register push tokens under.
deviceId: storedDeviceId,
// Your token endpoint; the SDK calls it on every (re)connect.
getToken: () => api.fetchCalloopToken(),
);Receive a call.
dart
agent.onIncoming.listen((call) {
// The caller's number, and the name your lookup hook gave.
showRinging(call);
// Then, as the user taps: call.accept() or call.decline().
});When the app is in the background or closed, Calloop wakes it with a push, and the SDK's native call screen shows a normal incoming call (Android today; see limits). Set it up once (Dart SDK → the native call screen): attach CalloopNativeCalls after connecting and call CalloopBackground.handlePush in your push handler. A Decline on the lock screen reaches Calloop straight away, without opening the app.
Make a call. Outbound always starts on your backend (you check the user may call, and bill it):
- The app asks your backend to call a number.
- Your backend creates the call on the user's own line:httpand returns
POST /v1/tenants/myapp/calls Idempotency-Key: <a new uuid> { "channel_ref": "did-442079460456", "agent_ref": "user-7", "customer": { "address": "+447700900789" } }call.callIdfrom the201 { call }response to the app. - The app starts it within 2 minutes:dartCalloop dials out through your trunk, showing the user's number as the caller.
final call = await agent.startOutbound(callId);
Only the line's owner can call out from it (anyone else gets 403 line_not_owned).
Reconnects, a user's second device, moving a call between them, refreshing tokens are handled by the SDK; your app draws the screens and reacts to the SDK's events.
7. Running it day to day
| Situation | What your backend does |
|---|---|
| The user turns on Do not disturb in your app | PUT /v1/tenants/myapp/agents/user-7/do-not-disturb { "enabled": true } — calls to their number ring nothing and arrive as missed calls. false to undo |
| Missed-call notification | On call.ended with terminationReason NO_ANSWER, BUSY or REJECTED, send your own push ("Missed call from …") |
| Call history in the app | Store call.ended events, or query GET /v1/tenants/myapp/calls?agent_ref=user-7 (paged), and serve them from your backend |
| Billing / usage | durations.call on each call.ended, or GET /v1/tenants/myapp/reports/agents for talk time per user |
| A user changes number | PUT the old line without owner_agent_ref (back to stock) and PUT the new one with it |
| A number moves to another user | PUT the line with the new owner_agent_ref |
| A user leaves or is suspended | DELETE /v1/tenants/myapp/agents/user-7. Their app is disconnected at once and their tokens stop working, even unexpired ones. Put their number back in stock (PUT without an owner). A later PUT of the agent restores them |
| You release a number to the carrier | DELETE /v1/tenants/myapp/channels/did-442079460456 |
| You missed webhooks during an outage | GET /v1/events lists them; POST /v1/events/{id}/redeliver sends one again |
8. What happens on each call
| Situation | The caller | call.ended |
|---|---|---|
| The user answers, then someone hangs up | Talks, then the call ends | COMPLETED, with durations.call |
| The user is already on a call | Busy (486) | BUSY |
| Nobody answers within the ring timeout | Call ends (480) | NO_ANSWER |
| The user declines | Call ends (480) | REJECTED, terminatedBy: AGENT |
| The user has no app connected and no push token registered | Call ends at once (480) | NO_ANSWER |
| The user is on Do not disturb | Call ends at once (480) | NO_ANSWER |
| The number has no owner (in stock) | Call rejected (480) | REJECTED, no agentRef |
| The caller hangs up while it rings | — | CANCELLED or NO_ANSWER |
Every missed call names the user (agentRef) and the number (channelRef), so your backend knows whom to notify. A user's own outbound calls end COMPLETED when answered; unanswered, terminationReason says why (for example REJECTED when the other side refuses or is busy).
9. Test before launch
Required. With one real number from your carrier:
- A call to the number rings the app open, in the background and closed (push and the native call screen)
- Answer: audio both ways; hang up from each side
- Busy, no answer, decline and Do not disturb each end as in the table above, and
call.endedreaches your webhook - An outbound call from the app: audio both ways, and the other phone shows the user's number
- A number in stock rejects calls; assigning it makes it ring the new owner
- Deleting a test user disconnects their app and their next connection is refused
- Webhook signatures verified and
event_idde-duplicated; your token endpoint only signs for the logged-in user
Before a real number exists, call your lines from a softphone on a test trunk (integration guide → Test before going live).
10. Current limits
| Area | Today |
|---|---|
| Outbound on your carrier | Needs your carrier to enable outbound calls on the trunk and allow your numbers as caller ID |
| iOS native call screen | The Dart SDK rings through the native screen on Android; on iOS your app handles PushKit/CallKit |
| Call waiting | A second caller hears busy while the user is on a call |
| Voicemail, call forwarding | Not supported |
| Calls between two of your users | Go out through the carrier and back in (they cost carrier minutes) |
| SIP trunk | IP-authenticated (no registration), UDP or TCP (no TLS yet); the carrier must send the SDP offer in its INVITE |
| SMS | Out of Calloop's scope |