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.
Registering an endpoint
Section titled “Registering an endpoint”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.
The events
Section titled “The events”| 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.
Verifying a delivery
Section titled “Verifying a delivery”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.
Respond quickly
Section titled “Respond quickly”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.
Handle duplicates
Section titled “Handle duplicates”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.
What happens when your server is down
Section titled “What happens when your server is down”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 hoursAbout 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.
If a secret leaks
Section titled “If a secret leaks”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.