Blog · For developers

Signed webhooks for call events

A webhook is a stranger posting to your server and saying it's us. The signature is how you check.

By the CosVoice team · · 5 minute read

Anyone can post to your endpoint

A webhook endpoint is a public address that accepts POST requests. That's all it is. Anybody who learns the address can send it a body saying a call completed and a table is booked for Friday. If your agent believes every body that arrives, your agent can be told anything.

Signing fixes that. We sign every event with a secret that belongs to your line, you check the signature before you trust the body, and a forged request fails the check. It's a few lines in any language. It's also easy to skip, because everything works without it, right up to the day it matters.

What arrives

You set an HTTPS address in Settings, under Connect, or your agent sets it with set_webhook. From then on we POST JSON to that address when something happens on the line.

The body is an envelope: an id, the event name, when it was created, which line it concerns, and a data object with the details. Four headers travel with it.

  • X-CosVoice-Event: the event name, so you can route without parsing the body.
  • X-CosVoice-Delivery: the id of this delivery.
  • X-CosVoice-Timestamp: when this attempt was sent, in Unix seconds.
  • X-CosVoice-Signature: the characters v1= followed by a hex string.

Checking the signature, in words

The signature is an HMAC using SHA-256. The key is your webhook secret. The message is the timestamp, then a full stop, then the raw request body. The last step below asks for a constant-time comparison, and there's a reason. An ordinary string compare gives up at the first character that differs, and how long it took can leak how close a guess was.

  • Read the raw body before any JSON parser touches it. Parsing and re-serialising can change spacing and key order, and the hash changes with them.
  • Join the timestamp header, a full stop, and the raw body, in that order.
  • Compute the HMAC with SHA-256 using your secret, as hex, and put v1= in front.
  • Compare the result with the signature header, in constant time.
  • If they differ, answer 401 and do nothing else.

Why the timestamp is part of what's signed

A validly signed request stays valid. If someone captures one, they can send it again next week and the signature still checks out. That's a replay. Because the timestamp is inside the signed message, nobody can alter it without breaking the signature, so you can turn away anything older than a few minutes and the check means something.

Each attempt we make carries its own fresh timestamp, so a retry that arrives an hour after the event won't trip your freshness check. And if you think the secret has been exposed, rotate it.

To see all this working, press Send test. A line.test event arrives and should pass. Change one character of the secret in your handler, press it again, and make sure it fails.

Retries mean duplicates

If your endpoint is down or answers with an error, we try again after 1 minute, 5 minutes, 15 minutes, 1 hour and 4 hours. An attempt that gets no answer within ten seconds counts as failed as well. After the last retry, we stop.

That's a kindness when your server restarts mid-deploy. It also means one event can arrive twice. Your handler did the work, took too long to answer, and we sent it again. So the handler has to be idempotent: doing it twice must leave things as they'd be after once. The practical way is to store the delivery id, which stays the same across retries of one event, and skip any you've already handled.

Don't count on order. A retried call.started can land after the call.completed for the same call.

What to do on each event

Whatever the event, the first three steps are the same: verify, store, answer with a 200. Do the real work afterwards. A handler that updates a calendar and emails the owner before it responds has turned one slow step into a retry. After that it depends on the event.

  • call.completed: the call is written up. Read the outcome, act on it, tell the owner. Don't report a booking unless the outcome says there is one. There's more on reading results in structured call outcomes.
  • message.received: a text reached the line. Save it and decide if it needs an answer. Texts from the line may be filtered for now, so a reply that has to arrive should be a call or an email.
  • email.received: mail reached the line's address. Read it with get_email, then forward it or reply from the line's own address.
  • call.started: a call connected, in either direction. Good for showing a live status. Not something to make decisions on.
  • line.test: somebody pressed Send test. Log it.

A webhook for code, a wake-up for agents

There's a second kind of delivery on our line, and the two are easy to mix up. The webhook above is for your code. Bot push is for starting an agent: you give us the address and key of a routine that runs on a webhook trigger, and we start it when the owner texts an instruction, when mail arrives, and when a call is written up. What we send says what happened in plain English, plus the ids the agent needs to read the whole thing.

Bot push is authorized with the bearer key you gave us, not with a signature. It gets two quick retries and then we stop, because waking something late is no use. The setup is in wake your assistant the moment a call ends, and both kinds are on the docs page.

Which one do you want? If a program is going to parse the event, the webhook. If an agent is going to read it and decide what to do, the wake-up.

Common questions

Why do webhooks need to be signed?

Because a webhook endpoint is a public address and anyone can post to it. A signature made with a secret only you and the sender hold lets you confirm the event is real and the body wasn't changed on the way.

What happens if my webhook endpoint is down?

We try again after 1 minute, 5 minutes, 15 minutes, 1 hour and 4 hours, then stop. If you were down for longer, recent_activity returns everything since a timestamp so you can catch up.

How do I avoid handling the same event twice?

Store the delivery id from the X-CosVoice-Delivery header when you process an event, and skip any delivery whose id you've already stored. Retries of one event carry the same id.

Do I have to use webhooks to know when a call ends?

No. You can poll get_call about every twenty seconds until the call is completed. The webhook saves you the loop.

Read next: Wake your assistant when a call ends · Placing a call over REST · What to do with the line's email address

Hear it for yourself.

Call our own AI agent and ask her anything, or get a number in your area code in about a minute.

More on for developers

Give your Chief of Staff a phone.

Pick a number, save the contact, make the first call. About a minute.

Get your line