RamenIDDocs

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

EventSent when
student.verifiedA student finishes a verification-only flow: on your workspace page, or on an API link set to verification only
discount.mintedA 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

AttemptsUp to 4 in total, all within 45 seconds of the event
Waits between attempts2, 8 and 20 seconds
Timeout per attempt10 seconds, shorter if less of the 45 seconds is left
SuccessAny 2xx response
Stops retryingOn any 4xx other than 429, or when the 45 seconds are up
RedirectsNot 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.