Skip to content

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 flow
  • GET /callback — receives the authorization code, exchanges it for an installation token, then immediately uses the token to fetch participants
  • POST /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_id and client_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 3000 or 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:

mkdir revento-quickstart && cd revento-quickstart
npm init -y
npm install express

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:

{
  "error": "insufficient_scope",
  "required": ["participants.read"],
  "request_id": "0f8a..."
}

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:

  1. Always verify the signature before trusting any body field. Without verification, anyone who finds your webhook URL could POST whatever they want.
  2. 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.
  3. 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.

  • If you're sketching the architecture of a real integrationPatterns and recipes for common shapes (sync, personalize-on-attendance, react-on-approval).
  • If you're building "Login with Revento" for a participant-facing appOAuth → Login with Revento. The flow is similar but the consent UX and token semantics differ.
  • If you're scoping your data needsScopes, then Read API → endpoints to map scopes to the data they unlock.
  • If you're sketching webhook handlersWebhooks → event catalog for every event type and payload shape.

Full code

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';

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'));