Verify calls to your app

Check that a scheduler call or a webhook delivery to your endpoint came from us, and refuse everything else.

Anyone who knows your endpoint’s address can call it. The scheduler and webhooks sign what they send, and the SDK checks the signature for you:

  • space.schedule.verify checks a call the scheduler makes to one of your job’s targets.
  • space.hooks.verify checks a webhook delivery to one of your destinations.

Neither needs an API key or a project. The check happens in your app. Both take the incoming Request, or its headers and raw body from any framework.

Verify a scheduler call

  1. Sign the job’s calls

    A job made with space.schedule.every signs its calls unless you pass signed: false. Each call then carries a token in its Authorization header.

    create-job.ts
    import { space } from "digitospace"
    
    const job = await space.schedule.every(
      "@daily",
      "https://shop.example.com/tasks/reminders"
    )
    console.log(job.id)

    The token is for the job’s audience: the target URL without its query, unless you set audience on the job.

  2. Check the call in your endpoint

    Pass the request and the audience to space.schedule.verify. Name the jobs that may call this endpoint in jobs, so a token made for another job is refused even when its audience matches.

    app/tasks/reminders/route.ts
    import { isSpaceError, space } from "digitospace"
    
    const jobs = [process.env.REMINDERS_JOB_ID ?? ""]
    
    export async function POST(request: Request): Promise<Response> {
      try {
        const call = await space.schedule.verify(request, {
          audience: "https://shop.example.com/tasks/reminders",
          jobs,
        })
        console.log(`Called by job ${call.job} in project ${call.project}`)
        return new Response(null, { status: 204 })
      } catch (error) {
        if (!isSpaceError(error)) throw error
        if (error.code === "invalid_signature")
          return new Response(null, { status: 401 })
        if (error.code === "unexpected_job")
          return new Response(null, { status: 403 })
        if (error.code === "keys_unavailable")
          return new Response(null, { status: 503 })
        throw error
      }
    }

    A token that is missing, badly signed, expired, from another issuer, or for another audience fails with invalid_signature and a status of 401. A token from a job not in jobs fails with unexpected_job and 403.

The SDK fetches the scheduler’s public keys and keeps them for 5 minutes, so most calls are checked without a request. When it cannot fetch them, it fails with keys_unavailable. Answering 503 fails the attempt, so the scheduler tries again if the job’s retry policy allows it.

See verify() for every option and field, and Verify calls to your target for how the token is made.

Verify a webhook delivery

  1. Get the destination’s signing secret

    Every delivery to a destination is signed with that destination’s signing secret, which starts with whsec_. Copy it from the destination in the console, or read it with the SDK:

    signing-secret.ts
    import { space } from "digitospace"
    
    const secret = await space.hooks.destinations.signingSecret(
      process.env.DESTINATION_ID ?? ""
    )
    console.log(secret)

    Keep it in your app’s secret settings, such as WEBHOOK_SECRET.

  2. Check the delivery in your endpoint

    Pass the request and the secret to space.hooks.verify. It reads the body, so read the delivery from what it returns rather than from the request.

    app/webhooks/payments/route.ts
    import { isSpaceError, space } from "digitospace"
    
    interface Payment {
      id: string
      amount: number
    }
    
    export async function POST(request: Request): Promise<Response> {
      try {
        const delivery = await space.hooks.verify(request, {
          secret: process.env.WEBHOOK_SECRET ?? "",
        })
        const payment = delivery.json<Payment>()
        console.log(`Delivery ${delivery.id} for payment ${payment.id}`)
        return new Response(null, { status: 204 })
      } catch (error) {
        if (isSpaceError(error) && error.code === "invalid_signature")
          return new Response(null, { status: 401 })
        throw error
      }
    }

    A delivery with a missing or wrong signature, or a timestamp more than 5 minutes from your server’s clock, fails with invalid_signature and a status of 401. Change the 5 minutes with toleranceSeconds.

See verify() for every option and field.

Verify with the raw body

When your framework gives you headers and a body rather than a Request, pass them as an object. The body must be exactly the bytes that arrived, as a string or a Uint8Array, before any JSON parsing.

server.ts
import { createServer } from "node:http"

import { isSpaceError, space } from "digitospace"

createServer(async (request, response) => {
  const chunks: Buffer[] = []
  for await (const chunk of request) chunks.push(chunk as Buffer)
  try {
    const delivery = await space.hooks.verify(
      {
        headers: request.headers as Record<string, string | undefined>,
        body: new Uint8Array(Buffer.concat(chunks)),
      },
      { secret: process.env.WEBHOOK_SECRET ?? "" }
    )
    console.log(delivery.body)
    response.writeHead(204).end()
  } catch (error) {
    const refused = isSpaceError(error) && error.code === "invalid_signature"
    response.writeHead(refused ? 401 : 500).end()
  }
}).listen(3000)

space.schedule.verify takes the same object form, and needs only headers.

Last updated