Webhooks
Get a signed request on your server the moment a student verifies or gets a discount code, and check that it really came from RamenID.
Set up
Add your endpoint
In Settings → Notifications, enter a public https:// URL on your server and choose which events to send. Saving the first time creates a signing secret (whsec_...).
Copy the signing secret
Click Reveal and copy it to your server, for example as RAMENID_WEBHOOK_SECRET.
Send a test event
Click Send test event. We send a sample student.verified for ana@mit.edu and show the response code your server returned.
Events
| Event | Sent when |
|---|---|
student.verified | A student finishes a verification-only flow: on your workspace page, or on an API link set to verification only |
discount.minted | A student gets a discount code. On a rule link, only when a new code is created (a returning student who gets the same code again does not trigger it). On an API link, every time the link is completed |
Both are on by default. Turning an event off also turns off its email alert. Full field reference: Webhook events.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: RamenID-Webhooks/1
RamenID-Signature: t=1760896931,v1=5f2b9c...{
"id": "9d2c4e1a-3b5f-4a7e-8c6d-2f1e0b9a8c7d",
"type": "discount.minted",
"created_at": "2026-10-19T18:02:11.000Z",
"data": {
"verification_id": "1f7a9c3e-5b2d-4e8f-a6c1-7d3b9e2f4a10",
"link_id": null,
"email": "ana@mit.edu",
"domain": "mit.edu",
"institution": "Massachusetts Institute of Technology",
"product_name": "Notebook Pro",
"coupon": {
"code": "STUK7M2QX",
"percent_off": 40,
"expires_at": "2026-10-26T18:02:11.000Z",
"checkout_url": "https://buy.stripe.com/test_123?prefilled_promo_code=STUK7M2QX"
}
}
}Verify the signature
Every request is signed with your secret, the same way Stripe signs its webhooks. The RamenID-Signature header has a timestamp t (Unix seconds) and a signature v1:
v1 = hex( HMAC-SHA256( secret, "{t}.{raw request body}" ) )The key is the whole secret, including whsec_. Use the raw body exactly as received: parsing and re-serialising the JSON changes it and breaks the check. Reject requests whose timestamp is more than 5 minutes old, to stop replays.
import crypto from "node:crypto";
// header is the RamenID-Signature value, like "t=1760896800,v1=5f2b…"
export function verifyRamenID(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.v1 || age > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}With Express, read the raw body for this route: app.post("/webhooks/ramenid", express.raw({ type: "application/json" }), handler), then pass req.body.toString().
import hashlib, hmac, time
def verify_ramenid(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if "v1" not in parts or abs(time.time() - int(parts.get("t", 0))) > 300:
return False
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])With Flask, use request.get_data() for the raw body.
Respond quickly
Return any 2xx status within 10 seconds. Do slow work after responding, or in a background job.
Retries
| Attempts | Up to 4 in total, all within 45 seconds of the event |
| Waits between attempts | 2, 8 and 20 seconds |
| Timeout per attempt | 10 seconds, shorter if less of the 45 seconds is left |
| Success | Any 2xx response |
| Stops retrying | On any 4xx other than 429, or when the 45 seconds are up |
| Redirects | Not followed |
If your endpoint times out every time, there is room for 3 attempts; the 4th only happens when earlier ones fail quickly. An endpoint that is down for more than a minute misses the event, so use Get a link to catch up on API links.
Each attempt is signed again with a fresh timestamp. The body, including the event id, stays the same, so use id to ignore duplicates.
The last 20 deliveries, with their status, appear under Recent deliveries in Settings → Notifications. There is no manual resend yet.
Endpoint rules
The URL must use https://, be publicly reachable (no localhost, .local, .internal or private IP addresses), contain no username or password, and be at most 500 characters. To test locally, use a tunnelling tool that gives you a public https URL.
Rotate the secret
Click Rotate in Settings → Notifications. The new secret works immediately and the old one stops working at once, so update your server right away.
Email alerts
Prefer email? Turn on Email alerts and enter an address. You get one email per event, for example "ana@mit.edu got their student discount for Notebook Pro", with the student's email, school and code. The same event switches control both webhooks and emails.