Webhooks
Registering an endpoint, verifying the signature, and what makes us switch one off.
Instead of asking us periodically what happened, you tell us where to push it.
Every event goes out as a POST, signed, with retries.
Registering
In the panel, under Webhooks, or over the API:
curl -X POST https://app.c-connect.pt/v1/webhooks \
-H "Authorization: Bearer $CCONNECT_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.company.com/cconnect",
"events": ["delivered", "bounced", "complained"]
}'
https only, with a valid certificate: the payload carries recipient addresses.
With no events, we send them all. An endpoint that receives nothing is worse
than one that receives too much, so the default is generous rather than
restrictive.
The answer carries the signing secret, once and only once. Keep it.
Verifying the signature
Every delivery carries this header:
X-CConnect-Signature: t=1758182462,v1=8f3c...9ab
t is the time in seconds. v1 is an HMAC SHA-256, with your secret, over the
string "<t>.<raw body>".
The raw body, byte for byte as it arrived. If you decode the JSON and encode it again, the signature stops matching, because key order and whitespace change.
$timestamp = ...; // the t from the header
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
if (! hash_equals($expected, $received)) {
abort(400);
}
Always use a constant time comparison, hash_equals in PHP. A === over
strings returns sooner when the first characters do not match, and that is
enough for an attacker to guess the signature one byte at a time.
Check that t is recent too. We allow five minutes of tolerance on our side; do
the same, and refuse a delivery older than that. It is what stops somebody
capturing a request and replaying it tomorrow.
What to answer
200, and quickly. If you take more than ten seconds we count the attempt as
failed.
The pattern that works is: verify the signature, put the event on a queue of
your own, answer 200, and process afterwards. An endpoint doing heavy work
before answering ends up looking broken on a busy day.
Anything outside 2xx counts as a failure, including a 301 to another URL: we
do not follow redirects, because a redirect is a way of sending the payload
somewhere you did not register.
The retries
Seven attempts, over about 33 hours:
| Attempt | After |
|---|---|
| 1 | immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 24 hours |
After that, that delivery is marked failed and is not attempted again.
When we switch an endpoint off
After ten consecutive failures, we disable the endpoint. In the panel it
shows as Disabled after repeated failures, and over the API the disabled_at
field is filled in.
It is said rather than done silently on purpose: an endpoint that stopped receiving with nobody noticing is why somebody discovers a month later that they lost every event.
One successful delivery resets the counter. To switch it back on, the Reactivate button in the panel, or:
curl -X PATCH https://app.c-connect.pt/v1/webhooks/{id} \
-H "Authorization: Bearer $CCONNECT_KEY" \
-d '{"enabled": true}'
Rotating the secret
Also in the panel. The new secret appears once, and deliveries already in flight were signed with the previous one: your system will refuse them if it has already changed. If that bothers you, accept both signatures for a few minutes before dropping the old one.