Sign-in codes by webhook
When a website asks one of your identities to verify, Joinpane can tell your own tools or a team chat as well as your phone. This is what we send and how to check it came from us.
Add a webhook
A webhook is yours, like your phone: it hears about the identities you're the phone owner for. In Joinpane, open Settings, then Your account, and under Sign-in codes choose Add URL.
- A public https address. Joinpane doesn't send to private networks.
- A signing secret, shown once. Your tool uses it to check each request. Changing the URL makes a new one.
- At the same moment as the app's notification, once, with 5 seconds to answer. A code doesn't wait, so we don't retry. Your account shows how the last delivery went.
- Never the code itself, nor a cookie, a password or an IP address. The code reaches the website only when a person types it.
What arrives
A POST with a JSON body. Any 2xx answer counts as delivered; we don't follow redirects.
| Header | What it is |
|---|---|
| Joinpane-Signature | t=<unix seconds>,v1=<hex>: how to check it is below. |
| Joinpane-Event | The event type, as in the body's type. |
| Joinpane-Delivery | Unique per delivery, the body's id: drop one you've seen. |
| Content-Type | application/json |
{
"type": "sign_in.code",
"id": "5f0c8a1e-6d2b-4c07-9a1f-3b7e2d9c4a10",
"createdAt": "2026-10-14T05:02:11.000Z",
"workspace": {
"slug": "northlight",
"name": "Northlight"
},
"identity": {
"id": "idn_01m4wmsm18knw0y9h145q7r4yp",
"name": "Studio uploads",
"website": "studio.example"
},
"waiting": "Ana",
"reminderFrom": null,
"expiresAt": "2026-10-14T05:12:11.000Z",
"url": "https://joinpane.com/codes/northlight/idn_01m4wmsm18knw0y9h145q7r4yp",
"text": "Studio uploads wants a sign-in code. studio.example sent a code to the phone on its account. Ana is waiting. https://joinpane.com/codes/northlight/idn_01m4wmsm18knw0y9h145q7r4yp",
"content": "The same as text, for Discord"
}| Field | What it is |
|---|---|
| type | What happened. See Event types. |
| workspace | slug and name. Null for a test. |
| identity | id, name, and the website it signs in to. Null for a test. |
| waiting | Who is waiting to get back in, if anyone. |
| reminderFrom | Someone asked you again: their name. |
| expiresAt | About when the website's code stops working. Codes usually last 10 minutes. |
| url | Where to answer it in Joinpane. |
| text | One line saying all of it, for a chat channel. |
Event types
| Type | Means |
|---|---|
| sign_in.code | The website sent a sign-in code to the phone on its account. |
| sign_in.approve | The website wants the sign-in approved in its own app. |
| sign_in.signed_out | The website signed the identity out. Someone needs to sign in again. |
| sign_in.blocked | The website refused the identity's IP address. |
| test | Send a test, from your account. |
Check it came from Joinpane
Joinpane-Signature holds when it was sent (t, in Unix seconds) and an HMAC-SHA256 of t, a full stop and the body, keyed with your signing secret (v1, in hex). Work it out from the body exactly as it arrived, before any JSON parsing, and compare in constant time.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// body: the request body exactly as it arrived, as a string.
export function fromJoinpane(body, signature, secret) {
const parts = Object.fromEntries(signature.split(",").map((part) => part.split("=")));
if (!parts.t || !parts.v1) return false;
// Refuse old deliveries, so a captured one can't be sent again later.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${body}`).digest("hex");
return (
expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
);
}Python
import hashlib, hmac, time # body: the request body exactly as it arrived, as bytes. def from_joinpane(body: bytes, signature: str, secret: str) -> bool: parts = dict(part.split("=", 1) for part in signature.split(",")) if "t" not in parts or "v1" not in parts: return False # Refuse old deliveries, so a captured one can't be sent again later. if abs(time.time() - int(parts["t"])) > 300: return False signed = parts["t"].encode() + b"." + body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts["v1"])
Chat channels
Every delivery carries the whole message as one line in text, and again in content. So a channel's incoming webhook URL from Slack, Google Chat or Discord works as it is: paste it into Add URL and the channel shows the line, with the link to answer it. A chat channel ignores the signature.
Anyone in the channel sees which identity needs a code. Only people who can answer it in Joinpane can type one: the identity's phone owners and the people who can drive it.
Telegram
Where Joinpane offers it, Sign-in codes has a Connect button for Telegram. It opens a private chat with Joinpane's bot; press Start, and requests arrive there with a button into Joinpane. Reply to a request with the code and we type it into the website, as if you'd entered it in the app. Send /stop to disconnect.
More on phone owners and the order we reach people in is on the Security page.