Call with the API
Place and answer calls with REST and a WebRTC session, from any language or platform.
In a browser, use the SDK
Call with the SDK does all of this for you, audio included. This page is for those who drive the calls themselves: another language, a server, a native app. Both follow the same steps.
A call needs two things: REST to place, answer and hang up, and a WebRTC session that carries the sound and reports what happens to the calls on its data channel.
Get a key
On a server, use your API key. In a browser or an app, never ship it: add a route to your server that asks 06 Line for an ephemeral key and returns it as is, after checking that your user may call:
export async function POST() {
const res = await fetch('https://api.06line.com/v1/ephemeral-keys', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.SIXLINE_API_KEY}` },
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}The answer holds the key, valid 15 minutes, and the ice_servers the WebRTC session needs. Ask for a new one about a
minute before it expires.
Open a WebRTC session
Create a peer connection with the ice_servers, add one audio track and a data channel labelled 06line, gather every
ICE candidate, then send the offer:
const { ephemeral_key: key, ice_servers: iceServers } = await fetch('/api/06line-key', { method: 'POST' }).then((r) => r.json());
const pc = new RTCPeerConnection({ iceServers });
pc.addTrack(microphoneTrack);
const events = pc.createDataChannel('06line');
await pc.setLocalDescription(await pc.createOffer());
await new Promise((done) => (pc.onicegatheringstatechange = () => pc.iceGatheringState === 'complete' && done()));
const res = await fetch('https://api.06line.com/v1/webrtc/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ sdp: pc.localDescription.sdp }),
});
const session = await res.json(); // { id: "rtc_…", sdp }
await pc.setRemoteDescription({ type: 'answer', sdp: session.sdp });There is no trickle ICE: the offer must hold every candidate. Audio is G.711 µ-law (PCMU), 8 kHz, which every WebRTC stack supports; keep the offer's default codecs and 06 Line picks it. An offer without PCMU gets no audio.
Keep the session open as long as the user may call or be called: it carries the audio of one call at a time, call after
call. Close it with DELETE /v1/webrtc/sessions/{id} or by closing the
peer connection: its call, if any, ends hangup. A session lost on the way (its network dropped and ICE failed) ends
its call failed; open a new one.
06 Line then sends JSON messages on the 06line channel; it is for receiving only, actions go through REST:
type | data | When |
|---|---|---|
session.ready | { "lines": Line[] } | Right after the channel opens: the lines this session can use |
line.state | a Line | A line went online or offline, or its signal changed (checked every 5 seconds) |
call.incoming | a Call | A call is ringing on one of the lines; also right after session.ready for a call already ringing |
call.state | a Call | A call of this session changed state, or a call that rang here stopped ringing |
events.onmessage = ({ data }) => {
const { type, data: payload } = JSON.parse(data);
if (type === 'session.ready') showLines(payload.lines);
if (type === 'line.state') updateLine(payload);
if (type === 'call.incoming') ring(payload);
if (type === 'call.state') updateCall(payload); // dialing, ringing, active, ended
};Let the user pick devices
The microphone is the track you add to the session; the speaker is where you play the track 06 Line sends back. To change either mid-call, swap the track or the output, the session stays as it is:
const mic = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: micSelect.value, echoCancellation: true } });
const microphoneTrack = mic.getAudioTracks()[0];
pc.ontrack = ({ streams }) => {
speaker.srcObject = streams[0]; // an <audio> element
void speaker.play(); // after a user action: browsers block sound before one
};
micSelect.onchange = async () => {
const next = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: micSelect.value } });
await pc.getSenders()[0].replaceTrack(next.getAudioTracks()[0]);
};
speakerSelect.onchange = () => speaker.setSinkId?.(speakerSelect.value); // not every browser can choose the output
muteButton.onclick = () => (microphoneTrack.enabled = !microphoneTrack.enabled);navigator.mediaDevices.enumerateDevices() lists the devices; their names stay empty until the user has granted
microphone access once.
Place a call
curl -X POST https://api.06line.com/v1/calls \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"line_id": "line_…", "to": "+33698765432", "session_id": "rtc_…"}'The call starts in dialing; call.state then reports ringing, active and ended. If the line cannot dial, the
response (still 201) already holds the call ended, with end_reason failed or line_offline. An unknown session or
a busy line is refused (404, 409) before anything is created.
Like SMS, a call takes an optional client_ref: the same request sent again returns the call already placed, with 200,
instead of dialing twice.
To hang up, POST /v1/calls/{id}/hangup. Hanging up a call already ended is not an error: it returns the call as it is.
Answer incoming calls
Every open session of a company receives call.incoming (a session opened with a key restricted to a line, only for
that line): ring everywhere, and answer with the session that should carry the sound:
curl -X POST https://api.06line.com/v1/calls/call_…/answer \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"session_id": "rtc_…"}'The response holds the call still ringing: it turns active with the call.state that follows, once the line picked
up. To decline, hang it up while it rings.
Every session it rang then receives call.state when it stops ringing: active once a session answered it (if it was
not yours, someone else took it: stop ringing), or ended if the caller gave up or nobody answered. From then on, only
the session that answered hears about the call. A late answer fails with line_busy if another session took the call,
or call_not_active if it stopped ringing.
How a call ends
end_reason | Meaning |
|---|---|
hangup | You hung up |
remote_hangup | The other party hung up |
no_answer | Nobody answered |
busy | The other party was busy |
line_offline | Your line lost the network |
failed | Any other failure, including the WebRTC session of the call lost on the way |
One call at a time per line and per session: a second one fails with line_busy.
Recordings
Every call is recorded, from the moment it is placed or answered until it ends, as up to two mono WAV files (PCM 16-bit, 8 kHz), one per speaker that made a sound; an incoming call nobody answered has none:
GET /v1/calls/{id}/recordings/remote: the other party, at the end of the phone line;GET /v1/calls/{id}/recordings/local: what your WebRTC session sent.
Both start at the same moment, so they line up: play them together, or transcribe each speaker on its own. They are ready a few seconds after the call ends.
Rather than polling, subscribe a webhook endpoint to call.recorded: it arrives once the files are
stored, with the call and a presigned link to each track there is, valid 7 days and usable without your API key, ready
to hand to a transcription service. A call with no file sends no call.recorded.
In most countries, including France, you must tell the other party that the call is recorded, and why, before recording it. Say it at the start of the call, or play a message.
Native apps
Nothing here is specific to browsers: the same session and REST calls work from any WebRTC stack.
| Platform | WebRTC library |
|---|---|
| iOS (Swift) | WebRTC.framework (Google's libwebrtc) |
| Android (Kotlin, Java, Compose) | org.webrtc (libwebrtc) |
| React Native | react-native-webrtc |
| Flutter | flutter_webrtc |
| Servers | Pion (Go), aiortc (Python), libdatachannel (C++) |
The data channel only lives while your app runs. Ringing a phone whose app is closed needs a push notification (PushKit
and CallKit on iOS, FCM and ConnectionService on Android); 06 Line does not send those yet.
History
GET /v1/calls lists past and current calls, newest first, filtered by line_id
and/or peer (the other party's number), exactly like SMS history.
Handling errors
Failures come back as an error with a code. The ones a calling app should handle:
code | What to do |
|---|---|
line_offline | The line has no network: show it, retry later |
line_busy | A call is already on the line or the session, or another session answered first |
call_not_active | The call stopped ringing before your answer |
not_found | Unknown call or session (or one of another line, with a key restricted to a line): open a new session |
rate_limited | Wait retry_after_ms, then retry |