Code examples
Working requests for common tasks, in cURL, Node.js and Python. Every request here is checked against the API description; each endpoint's page lists all its fields and errors.
Authentication
Check a key and its scopes
Send the key as a Bearer token. A valid key answers 200 with its scopes; a wrong or revoked key answers 401.
curl https://www.nutalk.ai/v1/auth/verify \ -H "Authorization: Bearer $NUTALK_API_KEY"
Agents
List agents
curl "https://www.nutalk.ai/v1/agents?limit=10" \ -H "Authorization: Bearer $NUTALK_API_KEY"
Change the instructions
New instructions are saved as a new draft version; the deployed version keeps answering until you deploy.
curl -X PATCH https://www.nutalk.ai/v1/agents/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer $NUTALK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"description": "Handles support and sales",
"instructions": "You are the front desk for Acme. Answer in one or two sentences."
}'Deploy the newest version
curl -X POST https://www.nutalk.ai/v1/agents/550e8400-e29b-41d4-a716-446655440000/deploy \ -H "Authorization: Bearer $NUTALK_API_KEY"
Contacts
Create a contact
Phone numbers are E.164 (+ and the country code). A phone that already belongs to a contact answers 409.
curl -X POST https://www.nutalk.ai/v1/contacts \
-H "Authorization: Bearer $NUTALK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+14155551234",
"first_name": "Jane",
"last_name": "Smith",
"email": "jane@example.com",
"company": "Tech Startup",
"tags": [
"prospect",
"inbound"
],
"fields": {
"interes": "Departamento"
}
}'Find contacts
search matches part of the phone number, a name or the email; tag filters by one tag.
curl "https://www.nutalk.ai/v1/contacts?search=jane&limit=10" \ -H "Authorization: Bearer $NUTALK_API_KEY"
Mark a contact as do not contact
Opts the person out of every campaign call and message; false lifts it.
curl -X PATCH https://www.nutalk.ai/v1/contacts/9595b646-ab97-4485-b1b8-e8b58dfcdd19 \
-H "Authorization: Bearer $NUTALK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"do_not_contact": true
}'Campaigns
Create a campaign
The campaign is created as a draft that calls from the channel within the calling hours (days left out are closed). Without rate_limits it keeps the default caps.
curl -X POST https://www.nutalk.ai/v1/campaigns \
-H "Authorization: Bearer $NUTALK_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"name": "Citas de octubre",
"description": "Call October'\''s leads to book a first appointment",
"type": "outbound",
"outbound_channel_id": "9f19ef32-eaef-452c-bebc-14d9759cdb64",
"purpose": "appointment_booking",
"agent_id": "550e8400-e29b-41d4-a716-446655440000",
"step_names_language": "es",
"business_hours": {
"timezone": "America/Monterrey",
"schedule": {
"monday": {
"start": "10:00",
"end": "18:00"
},
"tuesday": {
"start": "10:00",
"end": "18:00"
},
"wednesday": {
"start": "10:00",
"end": "18:00"
}
}
},
"api_import_enabled": true,
"retries": {
"enabled": true,
"max_retries": 2,
"delay_minutes": 120
}
}'Start it
curl -X POST https://www.nutalk.ai/v1/campaigns/f8d1c003-b292-448d-b8d8-1dd521b94b3b/start \ -H "Authorization: Bearer $NUTALK_API_KEY"
Add contacts in bulk
Up to 500 rows per request. Each row comes back enrolled, held (until the start), updated, skipped or rejected with a reason; a rejected row does not stop the others.
curl -X POST https://www.nutalk.ai/v1/campaigns/f8d1c003-b292-448d-b8d8-1dd521b94b3b/contacts/import \
-H "Authorization: Bearer $NUTALK_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"source": "my_crm",
"contacts": [
{
"phone_number": "+5215512345678",
"name": "Ana López",
"company": "Acme",
"external_id": "crm-1001"
},
{
"phone_number": "+14155550123",
"name": "Bob"
},
{
"phone_number": "12",
"name": "Invalid"
}
]
}'Read the results
Each enrollment carries its status, lead_response and final_call_outcome.
curl "https://www.nutalk.ai/v1/campaigns/f8d1c003-b292-448d-b8d8-1dd521b94b3b/enrollments?status=completed&limit=50" \ -H "Authorization: Bearer $NUTALK_API_KEY"
Pause it
curl -X POST https://www.nutalk.ai/v1/campaigns/f8d1c003-b292-448d-b8d8-1dd521b94b3b/pause \ -H "Authorization: Bearer $NUTALK_API_KEY"
Calls
Place a call
The channel's deployed agent makes the call. Send an Idempotency-Key so a retry never dials twice.
curl -X POST https://www.nutalk.ai/v1/calls \
-H "Authorization: Bearer $NUTALK_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"channel_id": "9f19ef32-eaef-452c-bebc-14d9759cdb64",
"to": "+14155551234",
"notes": "Follow-up call for demo scheduling",
"status_callback_url": "https://your-app.example.com/webhooks/call-status"
}'List calls
curl "https://www.nutalk.ai/v1/calls?status=completed&direction=outbound&limit=10" \ -H "Authorization: Bearer $NUTALK_API_KEY"
Get a call with its transcript and summary
curl https://www.nutalk.ai/v1/calls/1ba11348-e440-44de-a5cd-5fe62f4d9df6 \ -H "Authorization: Bearer $NUTALK_API_KEY"
Export calls to CSV (Node.js)
Calls are filtered by channel, direction and date; there is no campaign filter, so use the campaign's channel and start date.
// Node.js 18+: NUTALK_API_KEY=sk_... node export-calls.mjs
import { writeFileSync } from "node:fs";
const BASE = "https://www.nutalk.ai/v1";
const headers = { Authorization: `Bearer ${process.env.NUTALK_API_KEY}` };
// The outbound calls of one phone number since a date, every page.
async function outboundCalls(channelId, since) {
const calls = [];
for (let offset = 0; ; offset += 100) {
const query = new URLSearchParams({
direction: "outbound",
channel_id: channelId,
started_after: since,
limit: "100",
offset: String(offset),
});
const response = await fetch(`${BASE}/calls?${query}`, { headers });
const page = await response.json();
if (!page.success) throw new Error(`${page.error.code}: ${page.error.message}`);
calls.push(...page.data);
if (!page.pagination.has_more) return calls;
}
}
const csvCell = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const calls = await outboundCalls("<your voice channel id>", "2026-10-01T00:00:00Z");
const columns = ["id", "to_number", "status", "call_outcome", "duration_seconds", "started_at"];
const rows = calls.map((call) => columns.map((column) => csvCell(call[column])).join(","));
writeFileSync("calls.csv", [columns.join(","), ...rows].join("\n"));
console.log(`Exported ${calls.length} calls to calls.csv`);Webhooks
Register an endpoint
The response carries the signing secret once: store it.
curl -X POST https://www.nutalk.ai/v1/webhooks \
-H "Authorization: Bearer $NUTALK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/nutalk",
"events": [
"call.completed",
"call.failed"
],
"description": "Production CRM sync"
}'Verify the signature
import crypto from "node:crypto";
// rawBody: the request body exactly as received (a string, before JSON.parse).
// header: the X-NuTalk-Signature header. secret: the whsec_… from POST /v1/webhooks.
export function verifyNutalkSignature(rawBody, header, secret, toleranceMs = 5 * 60 * 1000) {
const parts = Object.fromEntries(
String(header || "").split(",").map((part) => part.split("=", 2))
);
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() - timestamp) > toleranceMs) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const given = Buffer.from(parts.v1 || "", "hex");
const wanted = Buffer.from(expected, "hex");
return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}Handle the events
import express from "express";
import { verifyNutalkSignature } from "./verify-nutalk-signature.js";
const app = express();
// Keep the raw body: the signature covers the exact bytes NuTalk sent.
app.post("/webhooks/nutalk", express.text({ type: "application/json" }), (req, res) => {
const ok = verifyNutalkSignature(
req.body,
req.get("X-NuTalk-Signature"),
process.env.NUTALK_WEBHOOK_SECRET
);
if (!ok) return res.status(401).send("Invalid signature");
const event = JSON.parse(req.body);
// A delivery can arrive more than once, each attempt with a new event.id:
// deduplicate on event.event + event.data.call_id.
console.log(event.event, event.data.call_id);
res.status(200).send("ok"); // answer within 10 seconds
});Calls send call.completed (answered) and call.failed (not answered); conversations send message.received, conversation.status_changed and conversation.assigned. See Webhooks for the payloads and retries.
End to end: run a campaign from your code
Creates a campaign on your voice number, starts it, imports leads, waits until every lead has finished and prints the results. The key needs campaigns:read and campaigns:write, and the number needs a deployed agent.
// Node.js 18+: NUTALK_API_KEY=sk_... node run-campaign.mjs
import { randomUUID } from "node:crypto";
const BASE = "https://www.nutalk.ai/v1";
// Enrollment statuses that will not change any more.
const FINISHED = new Set(["completed", "failed", "exhausted", "rejected", "removed"]);
async function api(method, path, body, idempotencyKey) {
const response = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.NUTALK_API_KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await response.json();
if (!payload.success) {
throw new Error(`${response.status} ${payload.error.code}: ${payload.error.message}`);
}
return payload;
}
// Every page of a list (100 per request).
async function listAll(path) {
const items = [];
for (let offset = 0; ; offset += 100) {
const separator = path.includes("?") ? "&" : "?";
const page = await api("GET", `${path}${separator}limit=100&offset=${offset}`);
items.push(...page.data);
if (!page.pagination.has_more) return items;
}
}
export async function runCampaign(channelId, leads) {
// 1. A draft campaign that calls from your voice number on weekdays, 9:00-18:00 New York time.
const weekday = { start: "09:00", end: "18:00" };
const { data: campaign } = await api(
"POST",
"/campaigns",
{
name: `Lead follow-up ${new Date().toISOString().slice(0, 10)}`,
outbound_channel_id: channelId,
business_hours: {
timezone: "America/New_York",
schedule: { monday: weekday, tuesday: weekday, wednesday: weekday, thursday: weekday, friday: weekday },
},
},
randomUUID()
);
// 2. Start it, then add the leads: each row comes back enrolled, skipped or rejected with a reason.
await api("POST", `/campaigns/${campaign.id}/start`);
const { data: imported } = await api(
"POST",
`/campaigns/${campaign.id}/contacts/import`,
{
source: "my_crm",
contacts: leads.map((lead) => ({ phone_number: lead.phone, name: lead.name, external_id: lead.id })),
},
randomUUID()
);
console.log(imported.summary);
// 3. Wait until every lead has finished. A campaign does not complete on its own:
// it stays active, ready for more contacts.
const deadline = Date.now() + 8 * 60 * 60 * 1000;
let enrollments = [];
while (Date.now() < deadline) {
enrollments = await listAll(`/campaigns/${campaign.id}/enrollments`);
const open = enrollments.filter((enrollment) => !FINISHED.has(enrollment.status));
console.log(`${enrollments.length - open.length}/${enrollments.length} finished`);
if (open.length === 0) break;
await new Promise((resolve) => setTimeout(resolve, 5 * 60 * 1000));
}
// 4. Stop calling and report.
await api("POST", `/campaigns/${campaign.id}/pause`);
for (const enrollment of enrollments) {
console.log(enrollment.phone_number, enrollment.status, enrollment.lead_response, enrollment.final_call_outcome);
}
return enrollments;
}
// await runCampaign("<your voice channel id>", [
// { id: "crm-1001", phone: "+14155551234", name: "Ana López" },
// { id: "crm-1002", phone: "+14155559876", name: "Luis Pérez" },
// ]);Starting a campaign dials the imported numbers within the calling hours and spends credits. Try it first with your own number.
Build with AI agents shows how to connect Claude Code, Codex or Cursor and walks through a calling agent end to end.