Interview100x partner API v1
Webhooks
practice.opened and practice.signed_up deliveries — ids and timestamps only — signed and retried.
Subscribe with POST /api/partner/v1/hooks (scope hooks:manage) with your url and, optionally, the events you want. The answer carries the signing secret, shown only once.
Events
| Event | When |
|---|---|
practice.opened | The person opened their practice link for the first time. |
practice.signed_up | The person signed in through the link (their practice account now exists). |
test.ping | A test delivery you asked for (POST /api/partner/v1/hooks/{id}/test). |
Plan created, practiced and purchased are never sent per person. Use GET /stats?month=YYYY-MM for a published month: each month counts the people who signed up that month and is final when published (on the 1st of the month after next). Plan created, practiced and purchased are returned only for the whole partnership; with a sub_partner_ref filter the response has invites, opened and signed up only. Groups under 10 come back as "hidden" after Signed up; other counts are rounded to the nearest 5 ("rounded": true), "<5" or "hidden" when they could identify someone. An unpublished month returns 422 "counts for this month are not published yet".
The JSON Schema is at webhooks.schema.json; samples at /developers/samples/{event}.json.
Verifying signatures
Deliveries are signed per Standard Webhooks: headers webhook-id, webhook-timestamp, webhook-signature, plus x-interview100x-event.
Node
const crypto = require("node:crypto");
// secret: the whsec_… value shown once when you created the endpoint.
// headers: the request headers (lower-case names). rawBody: the body exactly as received.
function verifyInterview100xWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"] || "";
if (!id || !timestamp) return false;
// Refuse messages older or newer than 5 minutes (replay protection).
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = "v1," + crypto
.createHmac("sha256", key)
.update(id + "." + timestamp + "." + rawBody)
.digest("base64");
// During a secret rotation the header carries two values; either may match.
return signatures.split(" ").some((candidate) =>
candidate.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)));
}
module.exports = { verifyInterview100xWebhook };Python
import base64, hashlib, hmac, time
# secret: the whsec_... value shown once when you created the endpoint.
# headers: a dict of request headers (lower-case names). raw_body: the body bytes as received.
def verify_interview100x_webhook(secret, headers, raw_body):
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature", "")
if not msg_id or not timestamp:
return False
# Refuse messages older or newer than 5 minutes (replay protection).
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
# During a secret rotation the header carries two values; either may match.
return any(hmac.compare_digest(candidate, expected) for candidate in signatures.split(" "))PHP
<?php
// $secret: the whsec_… value shown once when you created the endpoint.
// $headers: request headers with lower-case names. $rawBody: the body exactly as received.
function verifyInterview100xWebhook(string $secret, array $headers, string $rawBody): bool
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || $timestamp === '') {
return false;
}
// Refuse messages older or newer than 5 minutes (replay protection).
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = 'v1,' . base64_encode(hash_hmac('sha256', $id . '.' . $timestamp . '.' . $rawBody, $key, true));
// During a secret rotation the header carries two values; either may match.
foreach (explode(' ', $signatures) as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}Rotating the secret
POST /api/partner/v1/hooks/{id}/rotate returns a new secret once. The old secret keeps signing for 24 hours: each delivery in that window carries both signatures in webhook-signature, so a receiver verifying with either secret accepts it. Switch your receiver to the new secret within the 24 hours.
Retries and testing
A delivery without a 2xx answer is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. POST /api/partner/v1/hooks/{id}/test sends one signed test.ping now (at most 30 per 10 minutes per key).