talkieback

Server API and webhooks

From your server: read feedback, reply, change a status, delete a user. And receive every event, signed, at the address of your choice.

Authentication

Every address starts with https://talkieback.com/api/server/v1. Each call carries the app secret in the Authorization header. The secret starts with sk_live_: you create it on the “Verified identity” page or in Settings, For developers, and it only goes on your server, never in the app.

Terminal
curl "https://talkieback.com/api/server/v1/feedbacks?status=new&limit=20" \
  -H "Authorization: Bearer sk_live_…"

Responses are in JSON. When you regenerate a secret, the old one stays valid for 24 h, giving you time to update your server.

Limits

60 requests per minute per app, and a daily quota that depends on the plan. Beyond that, the response is 429: try again a little later. Deleting a user never counts.

Requests per day by plan
PlanRequests per day
Free1,000
Solo5,000
Pro100,000
Business500,000

Feedback

GET/feedbacks

The feedback of the app, newest first.

FieldWhat it does
statusOptional. A status: new, planned, in_progress, done, wont, spam.
limitOptional. From 1 to 100 items per page, 50 by default.
cursorOptional. The next_cursor value of the previous page.
Response
{
  "data": [ { "id": "3f6d2a9e-8c1b-4f5e-9a7d-2b4c6e8f1a3d", "kind": "voice", "status": "new", … } ],
  "next_cursor": "eyJjIjoiMjAyNi0xMC0yOVQxNDowMjoxMS4wMDBaIn0"
}

next_cursor is null on the last page.

GET/feedbacks/:id

One piece of feedback, with its text (or transcript), the page, the device and the user.

Response
{
  "id": "3f6d2a9e-8c1b-4f5e-9a7d-2b4c6e8f1a3d",
  "app_id": "8b0e5c2d-41f7-4c3a-b6e9-7d1f0a2c5e84",
  "kind": "voice",
  "text": "The button to swap a card no longer responds on the Axis album.",
  "route": "/album/axis",
  "status": "new",
  "reward": null,
  "tags": [],
  "test": false,
  "created_at": "2026-10-29T14:02:11.000Z",
  "resolved_at": null,
  "platform": "web",
  "app_version": null,
  "context": { "title": "Axis album", "lang": "en-US" },
  "user": {
    "id": "c4a1e7b2-9d3f-4e6a-8b5c-1f2e3d4c5b6a",
    "external_id": "42",
    "verified": true,
    "email": "camille@example.com",
    "name": "Camille"
  }
}

POST/feedbacks/:id/replies

Reply to the user. Talkieback lets them know in the widget, by notification or by email.

FieldWhat it does
bodyThe message. Optional if only status changes.
statusOptional. The new status of the feedback.
rewardOptional, when moving to “done” or on feedback already done. The reward shown to the user, passed on in feedback.rewarded.
internalOptional. true: an internal note, which the user doesn’t see.
Terminal
curl -X POST "https://talkieback.com/api/server/v1/feedbacks/3f6d2a9e-8c1b-4f5e-9a7d-2b4c6e8f1a3d/replies" \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"body": "Fixed in version 2.4. Thanks, Camille.", "status": "done", "reward": "5 boosters"}'

PATCH/feedbacks/:id

Change the status, the tags or the person following the feedback, without writing to the user.

FieldWhat it does
statusOptional. The new status.
tagsOptional. The full list of tags, for example ["album", "bug"].
assigneeOptional. The email of a team member, or null.
Terminal
curl -X PATCH "https://talkieback.com/api/server/v1/feedbacks/3f6d2a9e-8c1b-4f5e-9a7d-2b4c6e8f1a3d" \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"status": "planned", "tags": ["album"]}'

Users

DELETE/users/:externalId

Delete a user and all their feedback (GDPR). Never counted in the quota.

externalId is the ID your app passes to identify. The feedback, messages and audio are erased, with no way back.

Terminal
curl -X DELETE "https://talkieback.com/api/server/v1/users/42" \
  -H "Authorization: Bearer sk_live_…"
Response
{ "deleted": 3 }

Errors: 401 secret missing or refused, 404 not found, 422 invalid field (the message says which), 429 too many requests.

Webhooks

Add an address in Settings, Webhooks, and choose the events. Talkieback sends a JSON POST to it for each event. Respond with 2xx within 10 seconds; otherwise, Talkieback retries after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h, for 24 h.

Events
EventWhen
feedback.createdNew feedback arrives.
feedback.status_changedYou change the status of a piece of feedback.
feedback.resolvedFeedback moves to “Done” for the first time. Sent only once per piece of feedback, with the reward.
feedback.rewardedYour team gives a reward, when moving to “Done” or later. Sent for each reward: listen to it to credit the reward.
feedback.repliedYour team replies to a user.
feedback.user_repliedA user replies in the thread.

Each delivery has the same shape: id (unique: an event that comes back has the same one), type, created_at and data.

feedback.rewarded
{
  "id": "evt_8Kq2mXz4Tb7Rn3Wc9P",
  "type": "feedback.rewarded",
  "created_at": "2026-10-29T15:10:42.000Z",
  "data": {
    "feedback": { "id": "3f6d2a9e-8c1b-4f5e-9a7d-2b4c6e8f1a3d", "status": "done", "user": { "external_id": "42", "verified": true }, … },
    "reward": "5 boosters"
  }
}

Verify the signature

Each delivery carries the header Talkieback-Signature: t=1761750642,v1=5f2b…. v1 is HMAC-SHA256(webhook secret, t.body) in hexadecimal, where t is the send time in seconds and body the raw request body. Reject a wrong signature, or one older than 5 minutes.

Node (Express)
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();

// the raw body: the signature covers the bytes received, not the parsed JSON
app.post('/webhooks/talkieback', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = Object.fromEntries(
    (req.get('Talkieback-Signature') ?? '').split(',').map((p) => p.split('=')),
  );
  const expected = createHmac('sha256', process.env.TALKIEBACK_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body}`)
    .digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const valid =
    typeof parts.v1 === 'string' &&
    parts.v1.length === expected.length &&
    timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!fresh || !valid) return res.status(400).send('Invalid signature');

  const event = JSON.parse(req.body);
  // event.id is unique: ignore an event you already handled
  if (event.type === 'feedback.rewarded') {
    // the reward to credit: event.data.reward
  }
  res.sendStatus(200);
});

app.listen(3000);
Python (Flask)
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)


@app.post("/webhooks/talkieback")
def talkieback_webhook():
    header = request.headers.get("Talkieback-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    body = request.get_data()
    expected = hmac.new(
        os.environ["TALKIEBACK_WEBHOOK_SECRET"].encode(),
        parts.get("t", "").encode() + b"." + body,
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(400)
    if abs(time.time() - int(parts["t"])) > 300:
        abort(400)
    event = request.get_json()
    # event["id"] is unique: ignore an event you already handled
    return "", 200
PHP
<?php
$secret = getenv('TALKIEBACK_WEBHOOK_SECRET');
$body = file_get_contents('php://input');

$parts = [];
foreach (explode(',', $_SERVER['HTTP_TALKIEBACK_SIGNATURE'] ?? '') as $part) {
    [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
    $parts[$key] = $value;
}

$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $body, $secret);
$fresh = abs(time() - (int) ($parts['t'] ?? 0)) < 300;
if (!$fresh || !hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);
// $event['id'] is unique: ignore an event you already handled
http_response_code(200);

Verified identity

Pro and Business plans. Your server signs the ID of each signed-in user: hash = HMAC-SHA256(app secret, id), in hexadecimal, on the ID as text. Then it passes the ID and the hash to the button. Users find their feedback on all their devices, and no one can pretend to be someone else.

Node
import { createHmac } from 'node:crypto';

const hash = createHmac('sha256', process.env.TALKIEBACK_SECRET)
  .update(String(user.id))
  .digest('hex');
Python
import hashlib
import hmac
import os

user_hash = hmac.new(
    os.environ["TALKIEBACK_SECRET"].encode(),
    str(user.id).encode(),
    hashlib.sha256,
).hexdigest()
PHP
<?php
$hash = hash_hmac('sha256', (string) $user->id, getenv('TALKIEBACK_SECRET'));
supabase/functions/talkieback-hash/index.ts
// supabase functions deploy talkieback-hash
// supabase secrets set TALKIEBACK_SECRET=sk_live_…
import { createClient } from 'npm:@supabase/supabase-js@2';

const encoder = new TextEncoder();

async function hmacHex(secret: string, value: string): Promise<string> {
  const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(value));
  return Array.from(new Uint8Array(signature), (b) => b.toString(16).padStart(2, '0')).join('');
}

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors });
  // the signed-in user, from their Supabase token: never an id sent by the browser
  const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, {
    global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } },
  });
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return new Response('Unauthorized', { status: 401, headers: cors });
  const hash = await hmacHex(Deno.env.get('TALKIEBACK_SECRET')!, user.id);
  return Response.json({ id: user.id, hash }, { headers: cors });
});
HTML
<script>
  window.talkieback = window.talkieback || [];
  talkieback.push(['identify', { id: '42', email: 'camille@example.com', name: 'Camille' }, '<hash computed by your server>']);
</script>

Language

The button speaks French, English, German, Italian, Spanish, Dutch, Portuguese (Portugal and Brazil), Czech, Slovak, Polish, Hungarian, Turkish, Russian, Chinese, Japanese and Korean. With the language set to “The user’s” (Settings, Appearance), it uses the language you give it. If you don’t give one, or if it isn’t offered, it uses the language of the page (<html lang>), then those of the browser, and English if none is offered. Users can change it at the bottom of the panel.

If your app knows the account language, pass it, before or after loading, or in identify (lang or locale field). Voice is transcribed in the spoken language, whatever the panel language.

HTML
<script>
  window.talkieback = window.talkieback || [];
  talkieback.push(['lang', 'de']);
</script>

Notifications

On a website, a browser only accepts notifications from a service worker on your domain. Add this line to your service worker, or create the file /talkieback-sw.js at the root of your site with just this line:

Service worker
importScripts('https://cdn.talkieback.com/sw.js');

In a native app, add your Expo access token, or your FCM and APNs keys, in Settings, Notifications. The package registers the device on its own. If your app already has its own notifications, send them yourself when you receive feedback.replied.

Stuck on a step? Write to us.