Webhooks

Webhooks are available on the Enterprise plan only

You can receive webhooks for some Arcade events.

Currently, only form submissions are supported. If there are more events you would like to receive via webhooks, please let us know!

In your advanced settings, you can find your webhook secret and configure a URL to receive webhooks.

The webhook secret verifies the payload signature (see below).

Webhook URL

This is the URL where you want to receive webhooks.

Method: POST

Headers:

  • content-type: application/json

  • x-arcade-signature HMAC-SHA256-Base64 signature

  • x-arcade-signature-2 (optional)

Body:

{
  "type": "formSubmission.created",
  // "type": "formSubmission.updated",
  "timestamp": "2024-02-15T15:51:12.013Z",
  "flowId": "khM1A286JbRPxN9fxYsE",
  "sessionId": "b8103665-e6ce-4db8-894e-f123ec49ed9c",
  "viewerIp": "127.0.0.1",
  "stepTitle": "Step title (optional)",
  "stepSubtitle": "Step subtitle (optional)",
  "answers": {
    "User Email": {
      "variant": "email",
      "value": "foo@bar.com"
    },
    "User Name": {
      "variant": "text",
      "value": "Foo Bar"
    }
  }
}

Verifying signatures

The signature is a HMAC-SHA256 of the raw HTTP body, encoded with Base64.

Here's an example with a Next.js API route:

import crypto from 'node:crypto'
import type { NextApiRequest, NextApiResponse } from 'next'
import getRawBody from 'raw-body'

type FormSubmissionAnswer = {
  variant: 'email' | 'text' | 'date' | 'hidden' | 'single-select'
  value: string | number
}

type FormSubmissionEvent = {
  type: 'formSubmission.created' | 'formSubmission.updated'
  timestamp: string
  flowId: string
  sessionId: string
  viewerIp: string
  stepTitle?: string
  stepSubtitle?: string
  answers: Record<string, FormSubmissionAnswer>
}

type ArcadeEvent = FormSubmissionEvent

export const config = {
  api: {
    bodyParser: false,
  },
}

function getHeader(headers: NextApiRequest['headers'], name: string) {
  const value = headers[name]
  return Array.isArray(value) ? value[0] : value
}

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).end()
  }

  const signature = getHeader(req.headers, 'x-arcade-signature')

  // Optional, second signature using during signing secret rotation period
  const signature2 = getHeader(req.headers, 'x-arcade-signature-2')

  if (!signature && !signature2) {
    return res.status(401).end()
  }

  const rawBody = await getRawBody(req)

  const validSignature = crypto
    .createHmac('sha256', process.env.ARCADE_SIGNING_SECRET!)
    .update(rawBody)
    .digest('base64')

  if (signature !== validSignature && signature2 !== validSignature) {
    return res.status(403).end()
  }

  const event = JSON.parse(rawBody) as ArcadeEvent

  // Do something with the event

  res.status(200).end()
}

Or with a Next.js route handler (building off types defined above):

import { type NextRequest, NextResponse } from 'next/server'

export async function POST (request: NextRequest): NextResponse {
  const signature = req.headers.get('x-arcade-signature')

  // Optional, second signature using during signing secret rotation period
  const signature2 = req.headers.get('x-arcade-signature-2')

  if (!signature && !signature2) {
    return NextResponse.json({ error: 'Missing signature' }, { status: 401 })
  }

  const rawBody = await request.text()

  const validSignature = crypto
    .createHmac('sha256', process.env.ARCADE_SIGNING_SECRET!)
    .update(rawBody)
    .digest('base64')

  if (signature !== validSignature && signature2 !== validSignature) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 403 })
  }

  const event = JSON.parse(rawBody) as ArcadeEvent

 // Do something with the event

  return NextResponse.json({ success: true })
}

Last updated