API
Partner documentation

Invite webhooks

Receive one signed call each time a person joins Olfactorian through one of your invite links. Set up an https address, verify the signature and answer with a 2xx.

Getting started

What it is

Olfactorian sends your server one signed POST each time one of your invite links is used.

A link counts as used once the person has signed up, verified their email and received beta access. A click or a half-finished sign-up never counts.

Sign-up never waits on your server. The call goes out after the person has their access, and a slow or unreachable endpoint changes nothing for them.

The call carries no personal data and no customer identity. It names the code of the link that was used, the same code that sits beside the link in your link file, so you can match it to the person you sent it to. It also gives the time the link was used and the time the call was sent.

Getting started

Setting it up

Send Olfactorian an https address that accepts the call. Olfactorian sets it on your campaign and sends you the signing secret privately.

Address rules

  • It starts with https://.
  • It names a host with at least one dot, such as hooks.example.com.
  • The host is a name. An IP address, in any notation, is refused.
  • The host resolves, within 5 seconds, to public addresses only. Loopback, private, link-local, shared and multicast ranges, among others, are refused, for IPv4 and IPv6.
  • It uses the default port, 443. Any other port is refused.
  • It holds no user name or password and no # part.
  • It is at most 500 characters long.
  • Olfactorian does not follow redirects. An address that redirects counts as a failure, so give the final address.

Olfactorian checks the address when it sets it and again before every call, because a name can be pointed somewhere new in between.

The signing secret

The secret is 64 hexadecimal characters. Use it as that text, exactly as you received it. Never hex-decode it.

Your test campaign and your production campaign have different secrets, so keep one for each address.

Olfactorian can change the address while your campaign is on. The secret stays the same.

The call

The event

The body is compact JSON with three keys, in this order, and no spaces.

Body
{"inviteCode":"q7m2x9k4p8w3z6n1","redeemedAt":"2026-10-21T09:14:03.120Z","sentAt":"2026-10-21T09:14:04.000Z"}
ParameterTypeDescription
inviteCodestringThe code of the link that was used, the same text as the code beside it in your link file. A resend carries the same code.
redeemedAtstring (ISO 8601)It is the time the link was used, in UTC with milliseconds. A retry and a resend carry the same time.
sentAtstring (ISO 8601)It is the time of this try, in UTC with milliseconds. It is new on each try and matches the t of the signature to the second.

A campaign with one public link has no code per link. Its calls carry the campaign id as inviteCode.

The call

Headers

ParameterTypeDescription
Content-Typestringapplication/json
User-AgentstringOlfactorian-Webhooks/1
X-Olfactorian-Deliverystring (uuid)The id of this delivery. A resend carries the same id.
X-Olfactorian-Signaturestringt=<unix seconds>,v1=<hex>. The time of the try, and the signature of the body.
The call

Verify the signature

Each call is signed with your secret. Check the signature before you trust the call.

  1. Read the raw body bytes, exactly as they arrived. Never parse and re-encode the body first.
  2. Split the X-Olfactorian-Signature header on the comma, and read t and v1.
  3. Compute HMAC-SHA256(secret, "<t>.<raw body>") and write it as lowercase hex. The secret is the 64-character text.
  4. Compare your value with v1 in constant time.
  5. Refuse the call when t is more than 5 minutes from your clock.
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.OLFACTORIAN_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

const app = express();

app.post('/olfactorian/invites', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  const header = req.get('X-Olfactorian-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = parts.t;
  const given = parts.v1;
  if (!t || !given) return res.sendStatus(400);

  const ts = Number(t);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return res.sendStatus(400);

  const expected = crypto.createHmac('sha256', SECRET).update(t + '.' + rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(given);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  res.sendStatus(200);
  const event = JSON.parse(rawBody);
  // after answering: drop a repeat by event.inviteCode; on a public-link campaign, drop a repeat by its delivery id and by v1 for 5 minutes
});

A worked example

Use this to check your code before the first real call. The secret below is an illustration and belongs to no campaign.

Example
secret   abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
t        1792574044
body     {"inviteCode":"q7m2x9k4p8w3z6n1","redeemedAt":"2026-10-21T09:14:03.120Z","sentAt":"2026-10-21T09:14:04.000Z"}

signed text   1792574044.<the body above>
X-Olfactorian-Signature: t=1792574044,v1=b3b66b1be75235367501b1d26adf69a842f54fe7a2d8d2c781cf52180a1e217c

If you hex-decode the secret before signing, you get cfb842c19eb3627a76747b2ebfc959712f0cc50f9d2799a6ffc9a6b6d0e52279 instead. That value is wrong, and your code should never produce it.

The call

Your answer

Any 2xx status within 10 seconds counts as received. The 10 seconds cover finding your host, connecting and your answer.

Every other outcome is a failure. That includes any other status, a redirect, a timeout and a network error.

Keep the handler fast. Verify the signature, answer, and do the slow work afterwards.

Running it

Failures and resends

Olfactorian makes the first try seconds after the person joins. When a try fails, Olfactorian tries again 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours later, each wait counted from the latest try. That makes 7 tries in all. A resend from your Invites tab or by the Olfactorian team counts as one of the 7, and the next wait is counted from it.

After the seventh failed try the delivery is final and gets no more automatic tries. It stays Failed until you resend it from the Invites tab of your partner workspace, which you can do while its campaign is on, or until the Olfactorian team resends it. A delivery of a campaign that has been switched off gets no more automatic tries.

A sign-up that Olfactorian has to finish by hand gets its first try a minute or two after it is finished, which can be an hour or more after the person joined. A delivery gets at most 20 tries in all; after the twentieth it stays Failed.

A retry and a resend carry the same inviteCode, the same redeemedAt and the same X-Olfactorian-Delivery id. Each try has a new sentAt, a new t and a new signature, and its sentAt matches its t to the second.

  • On a campaign of single-use links, a code redeems once, so drop a repeat by its inviteCode.
  • Every call of a public-link campaign carries the same campaign id. Drop a repeat there by its X-Olfactorian-Delivery id, and by its v1 signature value for the 5 minutes a signature is accepted, because the delivery header is not signed.
Running it

Testing

Olfactorian sets up a test campaign in the test environment, with its own secret and a few links. Point it at a test address of yours, use a link, and your endpoint receives the same call a production campaign sends.

Check your verification against the worked example first. Then use a test link, and confirm that your endpoint answers with a 2xx and drops a repeat.

Running it

Watching deliveries

The Invites tab of your partner workspace shows each campaign's figures. Under Webhook it names the host the calls go to, and it lists each call with its day, its state (Sent, Waiting or Failed), the number of tries, the last answer and the day of the last try.

Talk to us

Running it

Using a no-code tool

Shopify Flow, Zapier and Make cannot all verify an HMAC signature. When yours cannot, put a small function in front of it. The function verifies the signature, answers, and forwards the event to the tool.

Invite webhooks — Olfactorian