Monitor and integrate
Connect n8n or Make
How it works
CallShift pushes, your workflow listens. When a call ends, CallShift posts a JSON body with everything about the call (summary, transcript, outcome, collected information, recording) to the address of your workflow. Your workflow has nothing to poll.
Build your workflow around these rules:
| Rule | Value | So your workflow |
|---|---|---|
| Event | End of call only | Gets nothing when a call starts or a transfer begins |
| Format | POST, Content-Type: application/json | Starts with a webhook trigger that accepts POST |
| Answer time | 10 seconds at most | Answers 200 at once and does the work afterwards |
| Retries | None | Catches up on undelivered calls through the API on a schedule |
| Signature | None | Checks a secret token in its address |
| Assignment | One webhook per agent | Can receive the calls of all your agents at one address |
The after-call webhook guide has the details of the delivery.
Set it up in four steps
Get the address of your workflow
The production address of an n8n Webhook node, or the address of a Make custom webhook (the next two sections). It must be public and in HTTPS: CallShift posts from the cloud.
Create the webhook
In the dashboard, at app.callshift.ai/webhooks: a name (n8n CRM sync) and the address. Or through the API, with POST
/v1/webhooks.Assign it to the agent
On the agent's page, tab After-Call Automations: turn on End of call event, pick the webhook in Select Webhook endpoint, then Save. Or through the API, with PUT
/v1/agents/{agentId}/webhook. Do it for each agent; a duplicated agent keeps the webhook of the agent it was copied from.Call the agent
Call a number the agent answers, then check that the workflow received the call (Test it end to end).
In n8n
Start the workflow with a Webhook node:
| Setting | Value |
|---|---|
| HTTP Method | POST |
| Path | Keep the random one n8n suggests: it is hard to guess |
| Respond | Immediately, so that CallShift gets its 200 within 10 seconds whatever the workflow does next |
- Give CallShift the production address,
/webhook/..., and activate the workflow. The test address,/webhook-test/..., only listens while the editor waits for a test event, for one call. - Add
?token=and a long random value to the address, and put an If node right after the webhook:{{ $json.query.token }}must equal your token, and the workflow stops otherwise. - The call is under
$json.body:{{ $json.body.callId }},{{ $json.body.summary }}. - To build the workflow before the first real call, paste the example payload below as the Webhook node's pinned data (Pin data).
In Make
Start the scenario with the Webhooks app, Custom webhook trigger, and create a webhook: Make gives you a public HTTPS address at once.
- The address is long and random: keep it secret, it plays the role of the token.
- To let Make learn the structure of a call, click Redetermine data structure, then send it the example payload below, saved as
callshift-payload.json:
curl -X POST "https://hook.eu1.make.com/<your-webhook-address>" \
-H "Content-Type: application/json" \
-d @callshift-payload.json- Turn the scenario on, scheduled Immediately. Make answers
200on receipt; if you add a Webhook response module, keep it before any slow module. - The fields are mapped as
{{1.callId}},{{1.contact.metadata.plan}}, where1is the number of the webhook module.
Make a self-hosted n8n reachable
A Make address and an n8n Cloud address work as they are. A self-hosted n8n often answers on an internal address that CallShift, in the cloud, cannot reach:
| In the address | What goes wrong | What to do |
|---|---|---|
n8n.company.local | A name only your network knows | Publish a public DNS name, such as a subdomain for webhooks |
:5678 | n8n's own port, closed to the outside | Put n8n behind a reverse proxy on port 443 |
http:// | Not encrypted, while the payload carries transcripts and phone numbers | HTTPS with a valid certificate |
/webhook-test/ | Listens only during a test in the editor | The production address, /webhook/, with the workflow active |
Three common ways to expose it, depending on your network policy: a reverse proxy you already run (nginx, Traefik, HAProxy, Caddy), an outbound tunnel that opens no inbound port (such as Cloudflare Tunnel), or n8n Cloud. Expose the /webhook/ path only, not the editor, and set n8n's WEBHOOK_URL variable to the public address. If your policy requires a list of source IP addresses, write to support@callshift.ai.
Check the address the way CallShift sees it: from a computer outside your network and without a VPN, a phone's connection sharing for instance. The name must resolve to a public IP address:
nslookup n8n.example.com 8.8.8.8And the address must answer a POST in under 10 seconds:
curl -i -X POST "https://n8n.example.com/webhook/<path>?token=<your-token>" \
-H "Content-Type: application/json" \
-d '{"test": true}' \
-w "\nTotal time: %{time_total} s\n"| Result | Meaning | What to do |
|---|---|---|
200, an execution in n8n, under 10 s | Ready | Test it end to end |
Could not resolve host | The name is not public | Publish the DNS name |
Timeout, Connection refused | Port closed or firewall | Open 443 and route it to the reverse proxy |
SSL certificate problem | Invalid or self-signed certificate | A recognised certificate, such as Let's Encrypt |
404, webhook not registered | Workflow inactive, or test address | Activate the workflow, use /webhook/ |
| Over 10 s | The workflow answers at its end | Set the Webhook node to respond Immediately |
The fields of a call
An example of what your workflow receives. The values are made up: check the structure on your first real call.
{
"callId": "8f14e45f-ceea-467a-9575-6d3c0a5f2b1e",
"type": "outbound",
"status": "COMPLETED",
"createdAt": "2026-10-30T09:11:58.000Z",
"startedAt": "2026-10-30T09:12:00.000Z",
"endedAt": "2026-10-30T09:13:24.000Z",
"duration": 84,
"from": "+33187654321",
"to": "+33612345678",
"callerNumber": "+33612345678",
"campaignId": "3b241101-e2bb-4255-8caf-4136c566a962",
"endedReason": "customer-ended-call",
"isReached": true,
"isSignificant": true,
"isSuccessful": true,
"isSilent": false,
"summary": "Ada booked a visit for Saturday at 10am.",
"transcript": "Assistant: Hello, this is Alice...\nUser: Hi, I'd like to book a visit...",
"recordingUrl": "https://recordings.example.com/recordings/8f14e45f.ogg",
"agent": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Alice" },
"phone": { "id": "0d3a7e6b-2b8e-4c5b-9c4e-5f4b9d2a1c8e", "number": "+33187654321" },
"entity": { "id": "9b2d3c4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", "externalId": "club-42", "name": "Lyon Part-Dieu" },
"qualificationCriteria": { "Visit booked": "true", "Preferred day": "Weekend" },
"contact": {
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"phoneNumber": "+33612345678",
"information": "VIP customer",
"metadata": { "plan": "pro" }
}
}| Field | Type | When | What it holds |
|---|---|---|---|
callId | UUID | Always | The call's id: the key to remove duplicates and to catch up |
type | text | Always | inbound, outbound or webcall |
status | text | Always | COMPLETED for a call that ended normally |
createdAt, startedAt, endedAt | ISO 8601 date, UTC | createdAt always; the other two null when unknown | When the call was created, started and ended |
duration | integer | When the call has its dates | Seconds, the same figure as the call's billingSeconds |
from, to | text | Usually | Outbound: your line, then the customer. Inbound: the customer, then your line |
callerNumber | text | Always | The customer's number, whatever the direction; "web-call" for a call from a browser |
campaignId | UUID or null | Campaign calls | The campaign that placed the call |
endedReason | text | When the call has one | Why it ended: see Ended reasons |
isReached, isSignificant, isSilent, isSuccessful | boolean | Once the call is analysed | The outcome: see Call analysis |
summary | text | Once the call is analysed | The summary, empty when nobody spoke |
transcript | text or null | When something was said | One line per turn, starting with User: or Assistant: |
recordingUrl | URL or null | Agents on the current voice engine | A direct link to the recording; can be empty |
agent | {id, name} | Always | The agent of the call |
phone | {id, number} or null | Always | Your CallShift line |
entity | {id, externalId, name} or null | Always | The call's entity, null when shared: see Entities |
qualificationCriteria | object | Once the call is analysed | One key per criterion of the agent, its value as text |
contact | object | When the number matches a contact | id, phoneNumber, firstName, lastName, email, information, metadata |
A field with no value is left out of the body: map it as optional.
Four mapping traps
- The criteria's keys are their exact names, accents and capitals included, and each agent has its own. Write them down from the agent's settings, and treat a name as frozen once a workflow reads it: renaming it empties the mapping without an error.
- The criteria's values are text. A yes or no arrives as
"true"or"false": compare it with the text, never with a boolean. In n8n:{{ $json.body.qualificationCriteria["Visit booked"] === "true" }}; in Make, a filter with the text operator Equal totrue. - The metadata is under the contact:
contact.metadata.plan, notmetadata.plan. A wrong path gives an empty value, without an error. - The dates are in UTC. Convert them to your time zone before writing them anywhere a person reads them. In n8n:
{{ DateTime.fromISO($json.body.endedAt).setZone("Europe/Paris").toFormat("yyyy-MM-dd HH:mm") }}; in Make,formatDatewith the time zone.
Test it end to end
No simulator is needed: a real call to a number the agent answers sends the webhook, a few seconds after hanging up (the time to write the summary and extract the information). Before calling, check that the address answers (the checks above), that the workflow is active on its production address, and that the webhook is assigned to the agent.
| Call to make | What the workflow should get |
|---|---|
| A complete conversation, up to the agent's objective | isSuccessful true, a clear summary, every expected criterion |
| A call the agent should transfer, if it transfers | endedReason assistant-forwarded-call |
| Hang up after two seconds | isSignificant false, an almost empty transcript: the workflow must not fail on it |
Then check:
callIdis there, and different on each call;callerNumberis the number you called from;transcriptandsummarymatch the conversation;- every criterion your workflow reads is there, with the name you mapped;
recordingUrlopens the recording;- the dates show in your time zone in your tools.
| Symptom | Likely cause | What to do |
|---|---|---|
| No execution in n8n or Make | Address unreachable, or workflow inactive | Run the address checks above again |
| Still nothing | Webhook not assigned to this agent, or not saved | Check the agent's After-Call Automations tab, then write to support@callshift.ai with the time of the call |
| An execution that fails | A mapping on a wrong path | Compare it with the raw body the execution received |
Every delivery, answered or failed, also shows in the logs of the dashboard, with your workflow's answer.
Catch up on undelivered calls
A delivery that fails is not sent again. A second workflow, on a schedule (every 30 minutes, every hour), reads the calls back through the API and adds the ones your destination does not have:
- a Schedule Trigger node in n8n, or a scheduled scenario in Make;
- an HTTP request to GET
/v1/calls, filtered by agent and by period (fromandto, full ISO dates on the creation time), with your API key in anAuthorization: Bearerheader stored in the tool's credentials, and the pages read up topageCount(see Pagination); - a check of each
callIdagainst what your destination already holds, to add only the missing ones; - for the full transcript of a call, GET
/v1/calls/{callId}.
curl "https://api.callshift.ai/v1/calls?agentId=$AGENT_ID&from=2026-10-30T08:00:00Z&to=2026-10-30T09:00:00Z&page=1&limit=100" \
-H "Authorization: Bearer $CALLSHIFT_API_KEY"Create a key for the workflow alone at app.callshift.ai/api-keys, and keep it in the tool's credentials, never in clear in a workflow you share.
Send contacts into a campaign
The other way round: your workflow adds contacts to a campaign, which calls them. Send them together, in one request to POST/v1/campaigns/{campaignId}/contacts, rather than one request per contact.
- Normalise the numbers to E.164 (
+33612345678) before sending them: CallShift matches contacts by number and does not normalise them for you. - Leave out an email you are not sure of. An email that is present but empty or malformed refuses the whole request with a
400; keep doubtful values inmetadata, which takes any JSON. - An active campaign dials at once. Send a test with one or two internal numbers first.
In n8n, a Code node can prepare each contact, here for French numbers:
// Mode "Run Once for Each Item": one contact per item, French numbers to E.164.
let phone = String($json.phone ?? "").replace(/[^\d+]/g, "");
if (phone.startsWith("00")) phone = "+" + phone.slice(2);
else if (phone.startsWith("0")) phone = "+33" + phone.slice(1);
const email = String($json.email ?? "").trim();
const validEmail = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
return {
json: {
phoneNumber: phone,
firstName: $json.firstName,
lastName: $json.lastName,
...(validEmail ? { email } : {}),
metadata: { source: "n8n", ...(email && !validEmail ? { rawEmail: email } : {}) },
},
};Then an Aggregate node gathers them into one contacts list, and an HTTP Request node posts it:
curl -X POST "https://api.callshift.ai/v1/campaigns/$CAMPAIGN_ID/contacts" \
-H "Authorization: Bearer $CALLSHIFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "phoneNumber": "+33612345678", "firstName": "Ada", "metadata": { "source": "n8n" } },
{ "phoneNumber": "+33687654321", "firstName": "Louis", "email": "louis@example.com", "metadata": { "source": "n8n" } }
]
}'Let an AI assistant build the workflow
Three prompts to paste into Claude, ChatGPT or Gemini. Each one carries what the assistant needs to know about CallShift: replace what is between brackets.
Build the receiving workflow
You are an expert in [n8n / Make]. Help me build a workflow that receives the end-of-call webhooks of the CallShift voice platform and sends them to [TARGET: our CRM, a Google Sheet, our ticketing tool].
CallShift's constraints:
- One JSON POST per ended call, sent once, never retried, with a 10-second timeout.
- No signature: the address carries a ?token= parameter the workflow must check first, rejecting the call when it is missing or wrong.
- Answer 200 at once and do the work afterwards.
- Remove duplicates on callId.
The body: at the root callId, type, status, createdAt, startedAt, endedAt (ISO 8601 in UTC), duration (seconds), from, to, callerNumber, campaignId, endedReason, isReached, isSignificant, isSilent, isSuccessful, summary, transcript, recordingUrl, agent {id, name}, phone {id, number}, entity {id, externalId, name} or null. qualificationCriteria is a flat object whose keys are the exact names of the agent's criteria and whose values are text ("true" or "false" for a yes or no). contact, absent when the number matches no contact, holds id, phoneNumber, firstName, lastName, email, information and metadata. A field with no value is left out. transcript is one block of text, one line per turn starting with "User:" or "Assistant:".
Here is a body my workflow received: [PASTE THE RAW JSON]
Give me the nodes or modules in order, the settings of each, the exact expression of each mapped field, the conversion of the dates to [TIME ZONE], and how to handle very short calls (isSignificant false).Make a self-hosted n8n reachable
You are a network engineer. Our n8n instance answers on an internal address, [INTERNAL ADDRESS, such as http://n8n.company.local:5678]. A SaaS platform in the cloud (CallShift) must send it HTTPS POST requests from the Internet, on the path /webhook/<id>. It sends each request once and expects an answer within 10 seconds.
Our context: [FIREWALL, DMZ, EXISTING REVERSE PROXY, HOW N8N IS HOSTED (Docker, VM), CONSTRAINTS OF OUR IT TEAM].
Suggest the simplest safe way to expose ONLY the /webhook/ path of n8n (not the editor), in HTTPS on port 443 with a valid certificate. Compare a reverse proxy in a DMZ, an outbound tunnel and n8n Cloud. Give the exact configuration of the option you recommend, the n8n environment variables to set (WEBHOOK_URL in particular), and the nslookup and curl commands to check it from outside our network.Catch up on undelivered calls
You are an expert in [n8n / Make]. The CallShift end-of-call webhook is never retried: when our endpoint is down, the call is lost for the webhook. Build a scheduled workflow, every [30 MINUTES / HOUR], that:
1. Calls GET https://api.callshift.ai/v1/calls with the header Authorization: Bearer <API key stored in the tool's credentials>, filtered by agentId and by period (from and to, full ISO 8601 dates, on the creation time).
2. Reads every page: the query takes page (from 1) and limit (up to 100), and the response gives data and pageCount.
3. Compares the callId of each call with those already in [TARGET] and adds only the missing ones.
4. For one call, GET https://api.callshift.ai/v1/calls/{callId} returns the full detail, transcript included.
Give me the nodes or modules, their settings and the exact expressions.