Skip to content

Webhooks

A webhook is how you learn a payment happened without polling. Jorvi signs every delivery, retries a failure for about a day, and gives up honestly rather than silently.

Terminal window
curl -X POST "$JORVI_API/v1/webhook_endpoints" \
-H "authorization: Bearer $KEY" \
-H 'content-type: application/json' \
-d '{"url":"https://your-server.example/jorvi/webhooks"}'

The response carries a secret beginning whsec_. Keep it. It is what proves a delivery came from us rather than from someone who guessed your URL.

A URL we would refuse to deliver to is rejected here, at registration, rather than accepted and quietly failing forever. A server that will POST to any address a customer names is a request-forgery tool, so refused at registration are: private and loopback IP addresses, cloud metadata endpoints, and reserved names that can never be public (localhost and anything under it, .local, .home.arpa, .internal).

A name that merely happens to resolve somewhere private cannot be known without DNS, so that is caught at delivery instead: the host is resolved and checked on every attempt, not once at registration, because the answer can change after you save it.

Event When
mandate.created An authorization was created. For a one-time purchase, when the order opens.
mandate.activated The payer’s signature landed on chain.
charge.succeeded Money moved. Carries the txSignature the chain returned.
charge.failed An attempt failed and will be retried.
charge.abandoned Retries are exhausted, or the allowance is gone. No more attempts.
charge.scheduled A future charge was placed on the schedule.
charge.skipped A charge was skipped because the mandate was no longer active.
mandate.completed Nothing further will be charged. For a one-time purchase, immediately after payment.
mandate.revoked The payer cancelled on chain, or dunning gave up.
mandate.expired It passed its end date.

If you only handle one, handle charge.succeeded. That is the one that means you have been paid and should ship the thing.

On a deployment that settles one-time purchases only, the recurring events (charge.scheduled, charge.failed, charge.abandoned) do not occur.

Every request carries a jorvi-signature header:

jorvi-signature: t=1780599650,v1=5e884898da28047151d0e56f8dc6292773603d0d6aabbdd6...

v1 is HMAC-SHA256(secret, "<t>.<raw body>") in hex. The timestamp is inside the MAC, which is what stops someone replaying a captured delivery at you later.

Verify against the raw bytes, before any JSON parsing. Re-serialising changes whitespace and key order, and the signature is over what was sent, not over what your parser reconstructed.

import { constructEvent } from '@jorvi/webhooks';
app.post('/jorvi/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = constructEvent(
req.body.toString('utf8'), // raw, not req.body as JSON
req.headers['jorvi-signature'],
process.env.JORVI_WEBHOOK_SECRET,
);
} catch {
return res.status(400).send('bad signature'); // do not process it
}
switch (event.type) {
case 'charge.succeeded':
// grant access, ship the thing. Idempotently: see below.
break;
}
res.json({ received: true });
});

Doing it by hand in another language: compute the HMAC, compare in constant time, and reject anything whose t is outside your tolerance. The default tolerance is 300 seconds.

Return 2xx as soon as you have stored the event. Any other status, or no response, counts as a failure and the delivery is retried.

Do the slow work afterwards. A handler that takes ten seconds to send an email is a handler that looks like an outage to us and gets retried while it is still working.

You will occasionally receive the same event twice. That is inherent: if your server processes a delivery and then fails to respond, we cannot tell that apart from never having received it, so we retry. At-least-once delivery is the strongest thing that can honestly be said.

Deduplicate on event.id, which is stable across retries. Record it, and make the second arrival a no-op.

The first attempt is immediate. If it fails, the delivery becomes a stored row and is retried on this schedule:

1 minute → 5 minutes → 15 minutes → 1 hour → 3 hours → 6 hours → 12 hours

About 22 hours of patience in total, then it is marked exhausted and no longer retried.

Two things worth knowing:

It survives our restarts. Pending deliveries are rows in the database, not entries in a queue in memory, so a deploy on our side does not drop your events. This is deliberate: a deploy is exactly when an in-memory queue would die.

Each attempt is signed afresh. A retry carries a current timestamp, not the timestamp of the first attempt. If it did not, a delivery retried an hour later would fall outside your tolerance window and you would reject precisely the deliveries that most needed to arrive.

A URL that our outbound policy refuses is not retried. Retrying cannot change that answer, and each attempt would be another connection to somewhere we have already decided not to go.

Register a new endpoint and stop trusting the old secret in your handler. Rotating a secret in place, and deleting an endpoint, are not yet exposed on the API. Said plainly rather than left for you to discover: there is currently no way to retire a whsec_ other than to ignore it.