Quickstart: first integration in 30 minutes¶
End-to-end walk: register → connect → API call → webhook. By the end you'll have a Node service that authenticates as an organizer, reads participant data, and processes a webhook.
URLs and credentials in this guide are placeholders.
URLs in this guide are placeholders marked with sandbox-...example. The flow is identical to what you'll run against the real sandbox; only the hostnames change.
What you'll build¶
A small Express server with two routes:
GET /connect— kicks off the OAuth flowGET /callback— receives the authorization code, exchanges it for an installation token, then immediately uses the token to fetch participantsPOST /webhooks/revento— receives webhook deliveries and verifies the signature
Total code: ~120 lines. We'll go through it in chunks; the full file is at the end.
Step 0: prerequisites¶
You read Before you start. You have:
- Sandbox
client_idandclient_secret - Webhook signing secret (issued at integration registration if you declared any webhook event types in your manifest)
- Node 20+ and a way to expose port 3000 to the public internet (use
ngrok http 3000or similar) - Sandbox URLs and a test event ID (from your account contact)
Your REDIRECT_URI must match what you registered, byte-for-byte.
The http://localhost:3000/callback value below assumes you registered exactly that URI. If you're using ngrok or another tunnel, register the tunnel's HTTPS URL (e.g. https://abc123.ngrok.io/callback) and set REDIRECT_URI to match. https://example.com/cb and https://example.com/cb/ (trailing slash) are different URIs.
Initialize a project:
Step 1: set up the server skeleton¶
// server.js
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
const CLIENT_ID = process.env.REVENTO_CLIENT_ID;
const CLIENT_SECRET = process.env.REVENTO_CLIENT_SECRET;
const WEBHOOK_SECRET = process.env.REVENTO_WEBHOOK_SECRET;
const REDIRECT_URI = 'http://localhost:3000/callback'; // must match what you registered
const AUTH_BASE = 'https://auth.revento.example';
const API_BASE = 'https://api.revento.example/api/v1';
// in-memory state for demo only — use a real store in production
const state = { codeVerifier: null, accessToken: null, refreshToken: null };
app.listen(3000, () => console.log('Listening on http://localhost:3000'));
The verify callback on express.json is critical for webhook signature verification — we need the raw body bytes, not the parsed JSON.
Step 2: kick off OAuth with PKCE¶
The OAuth handshake starts with a redirect from your server to Revento's /oauth/authorize endpoint. PKCE is required for all clients — public and confidential alike.
function base64url(buf) {
return buf.toString('base64')
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
app.get('/connect', (req, res) => {
// PKCE: generate verifier + challenge
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(
crypto.createHash('sha256').update(verifier).digest()
);
state.codeVerifier = verifier; // store for the /callback step
const url = new URL(`${AUTH_BASE}/oauth/authorize`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('scope', 'event.read participants.read');
url.searchParams.set('event_id', process.env.SANDBOX_EVENT_ID);
url.searchParams.set('state', 'demo-state-123'); // CSRF token in real code
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
res.redirect(url.toString());
});
Open http://localhost:3000/connect in a browser. You'll be redirected to Revento, see the consent screen, and after clicking Authorize you'll be redirected back to /callback with ?code=....
Step 3: exchange the authorization code for tokens¶
app.get('/callback', async (req, res) => {
const { code, state: returnedState } = req.query;
if (returnedState !== 'demo-state-123') {
return res.status(400).send('State mismatch — possible CSRF');
}
const tokenResponse = await fetch(`${AUTH_BASE}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: state.codeVerifier,
}),
});
if (!tokenResponse.ok) {
const err = await tokenResponse.text();
return res.status(500).send(`Token exchange failed: ${err}`);
}
const tokens = await tokenResponse.json();
state.accessToken = tokens.access_token;
state.refreshToken = tokens.refresh_token;
// ... continues in step 4
});
A successful response looks like:
{
"access_token": "rev_install_8h3k...",
"refresh_token": "rev_refresh_a92c...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "event.read participants.read",
"event_id": "evt_abc123",
"organization_id": "org_xyz789"
}
Store access_token (short-lived, refresh as needed) and refresh_token (long-lived, treat like a password). Note event_id and organization_id in the response — these tell you which (event, org) tuple this token is bound to.
Step 4: make your first API call¶
Right after the token exchange in /callback, use the access token to fetch the event's participants:
const participantsResponse = await fetch(
`${API_BASE}/events/${tokens.event_id}/participants?limit=10`,
{
headers: {
'Authorization': `Bearer ${tokens.access_token}`,
'Accept': 'application/vnd.revento.v1+json',
'X-Revento-Request-Id': crypto.randomUUID(),
},
}
);
const participants = await participantsResponse.json();
res.json({
message: 'Connected! First 10 participants below.',
participants,
});
});
You should see a JSON response with participant data. If the event has fewer than 10 participants you'll get them all; otherwise paginate using the next_cursor field.
If something's wrong with your token or scopes, you'll get a structured error:
The request_id matches what you sent in the X-Revento-Request-Id header — useful when asking support to look up what happened.
Step 5: receive a webhook¶
Webhook deliveries POST to your registered URL with a JSON body and signing headers:
app.post('/webhooks/revento', (req, res) => {
const eventType = req.headers['x-revento-event'];
const deliveryId = req.headers['x-revento-delivery-id'];
const timestamp = req.headers['x-revento-timestamp'];
const signature = req.headers['x-revento-signature']; // "sha256=..."
// 1. Reject deliveries with a stale timestamp (replay protection)
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > 300) {
return res.status(401).send('Stale timestamp');
}
// 2. Verify the HMAC signature
// Use two .update() calls — concatenating timestamp + rawBody via template
// literal would coerce the Buffer to a UTF-8 string and corrupt non-UTF-8 bytes.
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
hmac.update(`${timestamp}.`);
hmac.update(req.rawBody);
const expected = 'sha256=' + hmac.digest('hex');
const expectedBuf = Buffer.from(expected);
const sigBuf = Buffer.from(signature || '');
if (
expectedBuf.length !== sigBuf.length ||
!crypto.timingSafeEqual(expectedBuf, sigBuf)
) {
return res.status(401).send('Bad signature');
}
// 3. Now you can trust the body
console.log(`[${eventType}] delivery ${deliveryId}`, req.body);
// 4. Respond fast — you have 10 seconds; queue real work async
res.status(200).send('ok');
});
Three rules to internalize:
- Always verify the signature before trusting any body field. Without verification, anyone who finds your webhook URL could POST whatever they want.
- Reject deliveries with stale timestamps. Even with a valid signature, a replay attack works if you accept old deliveries indefinitely. Five minutes is the contract; tighten to a minute if you want.
- Respond within 10 seconds. That's the per-attempt timeout. If your real work takes longer, queue it on your side and return 200 immediately — otherwise Revento will count the delivery as a failure and start retrying, and you'll process the same event multiple times.
To trigger a test webhook: change something in your sandbox event (approve an application, publish the event) — the delivery should land within seconds. You can also replay any past delivery from the developer console.
Step 6: refresh the token when it expires¶
Access tokens last 1 hour. When you get a 401 token_expired response, refresh:
async function refreshAccessToken() {
const r = await fetch(`${AUTH_BASE}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: state.refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const tokens = await r.json();
state.accessToken = tokens.access_token;
state.refreshToken = tokens.refresh_token; // refresh tokens rotate — store the new one
return tokens.access_token;
}
Refresh tokens are one-time use. Each refresh returns a new refresh token; the old one is invalidated. If you accidentally use a refresh token twice (e.g. two parts of your code race on refresh), Revento revokes the entire token family and the user has to re-consent. See Refresh tokens for how to avoid this.
You did it¶
You have:
- A working OAuth flow with PKCE
- A valid installation token
- A successful API call against real (sandbox) data
- A webhook receiver that verifies signatures and respects timing constraints
- A refresh path for when access tokens expire
This is the entire shape of a Revento integration. Everything else in this guide is depth: more endpoints, more event types, scope semantics, error handling, edge cases.
What to read next¶
- If you're sketching the architecture of a real integration → Patterns and recipes for common shapes (sync, personalize-on-attendance, react-on-approval).
- If you're building "Login with Revento" for a participant-facing app → OAuth → Login with Revento. The flow is similar but the consent UX and token semantics differ.
- If you're scoping your data needs → Scopes, then Read API → endpoints to map scopes to the data they unlock.
- If you're sketching webhook handlers → Webhooks → event catalog for every event type and payload shape.
Full code¶
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
const CLIENT_ID = process.env.REVENTO_CLIENT_ID;
const CLIENT_SECRET = process.env.REVENTO_CLIENT_SECRET;
const WEBHOOK_SECRET = process.env.REVENTO_WEBHOOK_SECRET;
const REDIRECT_URI = 'http://localhost:3000/callback';
const AUTH_BASE = 'https://auth.revento.example';
const API_BASE = 'https://api.revento.example/api/v1';
const state = { codeVerifier: null, accessToken: null, refreshToken: null };
const base64url = (buf) => buf.toString('base64')
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
app.get('/connect', (req, res) => {
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
state.codeVerifier = verifier;
const url = new URL(`${AUTH_BASE}/oauth/authorize`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('scope', 'event.read participants.read');
url.searchParams.set('event_id', process.env.SANDBOX_EVENT_ID);
url.searchParams.set('state', 'demo-state-123');
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
res.redirect(url.toString());
});
app.get('/callback', async (req, res) => {
const { code, state: returnedState } = req.query;
if (returnedState !== 'demo-state-123') return res.status(400).send('State mismatch');
const tokenResp = await fetch(`${AUTH_BASE}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: state.codeVerifier,
}),
});
if (!tokenResp.ok) return res.status(500).send(await tokenResp.text());
const tokens = await tokenResp.json();
state.accessToken = tokens.access_token;
state.refreshToken = tokens.refresh_token;
const partsResp = await fetch(
`${API_BASE}/events/${tokens.event_id}/participants?limit=10`,
{ headers: {
'Authorization': `Bearer ${tokens.access_token}`,
'Accept': 'application/vnd.revento.v1+json',
'X-Revento-Request-Id': crypto.randomUUID(),
}
}
);
res.json({ message: 'Connected!', participants: await partsResp.json() });
});
app.post('/webhooks/revento', (req, res) => {
const ts = req.headers['x-revento-timestamp'];
const sig = req.headers['x-revento-signature'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return res.status(401).send('Stale timestamp');
}
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
hmac.update(`${ts}.`);
hmac.update(req.rawBody);
const expected = 'sha256=' + hmac.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(sig || '');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('Bad signature');
}
console.log(`[${req.headers['x-revento-event']}]`, req.body);
res.status(200).send('ok');
});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));