Webhook Setup

Create a Webhook

  1. Navigate to Settings > Webhooks.
  2. Click Create Webhook.
  3. Select the team and fill out the webhook URL.
  4. Toggle the status to Activated.
  5. Save Changes.
⚠️

Only one webhook is allowed per team.

Vision Dashboard.

Vision Dashboard. Configuring webhooks in the interface.

You can also create webhooks with the API. This is useful when you create teams programmatically and want to configure each team's webhook as part of your provisioning flow. See the webhook creation API reference.

Verify webhook events from Truepic

You can use one or more verification options to confirm webhook events are coming from Truepic:

  • Webhook signature: Configure a webhook secret. Truepic signs each request and includes the signature in the truepic-signature header so your endpoint can verify the payload.
  • Custom headers: Configure static headers that Truepic sends with each webhook delivery, such as a gateway credential or routing header required by your endpoint.
  • Mutual TLS: Configure mTLS if your endpoint requires client-certificate authentication.

We recommend verifying the truepic-signature header for webhook payload integrity, even if you also use custom headers or mTLS.

Dynamic values in custom headers

If a custom header value is exactly {{ UUID }} including the spaces, Truepic replaces it with a new v4 UUID on every delivery attempt. Use this value for request correlation in your access logs.

Truepic generates a new UUID for each retry of the same webhook event, so this value is not suitable as an idempotency key. For idempotency, deduplicate using stable fields in the webhook payload, such as the inspection ID and event action.

Verify with a webhook secret

If you provide a secret for your webhook, Truepic includes a truepic-signature header in each webhook activity that can be verified to ensure it is coming from Truepic. While this is optional, we recommend it for increased security.

The truepic-signature looks like this:

truepic-signature: t=1634066973,s=6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU

There is a t value that is the timestamp of when the request was sent, and an s value that is the signature of the request. The signature can be either auto-generated by Truepic, or provided by you.

Verify webhooks with the official Node.js library

If you're using Node.js, install the officially supported @truepic/webhook-verifier package and call it at the beginning of your webhook route. It verifies the integrity of the payload, the authenticity of the sender, the authenticity of the receiver, and the request timestamp to help prevent replay attacks.

See the @truepic/webhook-verifier package on npm for package details and additional documentation.

npm install @truepic/webhook-verifier
import verifyTruepicWebhook from '@truepic/webhook-verifier'
import express from 'express'
import { env } from 'node:process'

const app = express()

app.post(
  '/truepic/webhook',
  // Use the raw body so the verifier can compare the exact payload that was signed.
  express.raw({
    type: 'application/json',
  }),
  (req, res) => {
    try {
      verifyTruepicWebhook({
        url: env.TRUEPIC_WEBHOOK_URL,
        secret: env.TRUEPIC_WEBHOOK_SECRET,
        header: req.header('truepic-signature'),
        body: req.body.toString(),
      })
    } catch (error) {
      console.warn(error)
      return res.sendStatus(200)
    }

    const body = JSON.parse(req.body.toString())

    console.log(`Processing webhook: ${body.type}`)

    return res.sendStatus(200)
  }
)

If the webhook is verified, verifyTruepicWebhook returns true. If verification fails, it throws a TruepicWebhookVerifierError describing what failed.

If you're not using Node.js, use the manual verification flow below as a reference implementation.

Parse the Header Value

To start, split the header value on the comma (,). This should leave you with two parts:

  • t=1634066973
  • s=6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU

Next, split each part on the equals (=). This should leave you with two values for each part:

  • t
  • 1634066973
  • s
  • 6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU

Verify the Timestamp

The timestamp (t) can be verified to ensure it is a recent request and not a potentially delayed replay attack. Some leeway should be allowed in case the clocks on either end of the request are not quite in sync.

Here is our service with 5 minutes of leeway:

/* Services */
function verifyTimestamp({ timestamp, leewayMinutes = 5 }) {
  const diff = Math.abs(Date.now() - timestamp * 1000)
  const diffMinutes = Math.ceil(diff / (1000 * 60))

  return leewayMinutes >= diffMinutes
}

Verify the Signature

The signature (s) can be verified to ensure the recipient and payload have not been tampered with. This is done by rebuilding the signature – an HMAC digest using SHA-256 that is Base64-encoded – with a secret that only Vision and you are privy to.

To create the message to sign, join your webhook URL together with the timestamp (t) and the raw request body using a comma (,). It is important to use the raw request body (a string) before it is parsed as JSON, as different languages/frameworks can parse/stringify JSON in subtly different ways, which could result in different signatures.

Next, sign the {{url}},{{timestamp}},{{body}} message with the secret and compare the Base64-encoded signatures with a constant-time algorithm to prevent timing attacks.

Here is how that looks in our service that relies on Node.js’s crypto module for all of the heavy lifting:

/* Dependencies */
import { createHmac, timingSafeEqual } from 'crypto'

/* Services */
function verifySignature({ url, timestamp, body, secret, signature }) {
  const comparisonSignature = createHmac('sha256', secret)

  comparisonSignature.update([url, timestamp, body].join(','))

  return timingSafeEqual(
    Buffer.from(comparisonSignature.digest('base64'), 'base64'),
    Buffer.from(signature, 'base64')
  )
}

Putting It All Together

Now that we have services to parse and verify the truepic-signature header, update the webhook route to put it all together:

/* Constants */
const WEBHOOK_URL = `http://localhost:${PORT}/webhook`
const WEBHOOK_SECRET = 'secret'

/* Routes */
app.post(
  '/webhook',
  express.raw({
    type: 'application/json',
  }),
  (req, res, next) => {
    const { timestamp, signature } = parseSignatureHeader(
      req.header('truepic-signature')
    )

    const isTimestampVerified = verifyTimestamp({ timestamp })

    if (!isTimestampVerified) {
      console.warn('Invalid timestamp')
      return res.sendStatus(200)
    }

    const isSignatureVerified = verifySignature({
      url: WEBHOOK_URL,
      timestamp,
      body: req.body.toString(),
      secret: WEBHOOK_SECRET,
      signature,
    })

    if (!isSignatureVerified) {
      console.warn('Invalid signature')
      return res.sendStatus(200)
    }

    next()
  },
  (req, res) => {
    const body = JSON.parse(req.body.toString())

    console.log(`Processing webhook: ${body.type}`)
    console.dir(body, { depth: null })

    res.sendStatus(200)
  }
)