Caller identity guide

Verify customer identity with a signed JWT

Every WhatsApp message already carries a channel-verified phone number. Drusy attaches that number to custom HTTP actions in X-Drusy-Identity, a short-lived JWT your endpoint can verify.

Overview

This guide is for a tenant whose custom skill calls POST /agent-tooling/actions with type http and needs the customer's WhatsApp number to look up the right account, order, or booking.

On by defaultJWTES256

Do not trust

A model-filled phone field

Anything in an action input schema is something the model can invent and a customer can try to change in chat.

Trust after verify

The signed identity token

Drusy mints the token from the channel delivery record, signs it, and sends it outside the model-controlled tool arguments.

Authorization is left alone. That header belongs to the auth scheme you configured for the action. X-Drusy-Identity is about the customer.

Prompt awareness

The assistant also sees the caller's number in per-turn context so it stops asking for it or guessing it. That context improves the conversation, but it is not how your backend receives the number.

system prompt excerpt
Customer:
- WhatsApp number: +2348012345678 (verified by the channel)
- Name: Ada Okonkwo
Their number is already attached to every tool call automatically. Never ask
for it, and never put it in a tool argument unless a field explicitly asks
for a different person's number.

The token

When the caller phone number is known, every outbound custom HTTP action request includes one signed JWT header.

request header
X-Drusy-Identity: eyJhbGciOiJFUzI1NiIsImtpZCI6ImsxIiwidHlwIjoiSldUIn0...
sub+2348012345678

The customer's phone number, in E.164.

isshttps://drusy.ai

Always this. Pin it.

audhttps://api.example.com

The origin Drusy sent the request to. Pin your own origin.

exp / iat1754300300

Five-minute lifetime.

jti9f2c...

Unique per request, for an optional replay cache.

biz507f1f77bcf86cd799439011

The Drusy business this request belongs to.

actlookup_order

The action that fired, for optional per-action checks.

bhn4bQgYhMfW...

base64url SHA-256 of the raw request body, when there is one.

Where to do this in DrusyAISkills > your action > Auth tab > Send customer identity

Getting your keys

Each business gets its own ECDSA P-256 keypair. Drusy holds the private key. Your server uses the public key through JWKS.

Where to do this in DrusyAISettings > Credentials > Customer verification key
JWKS URL
GET https://api.drusy.ai/public/signing-keys/:businessId/jwks.json
example response
{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "x": "...",
      "y": "...",
      "kid": "k1",
      "alg": "ES256",
      "use": "sig"
    }
  ]
}
Prefer JWKS over hand-pinned PEM. Active and retired keys are both published, and the token header includes kid so your library can pick the right key during rotation.

Verify the JWT

Reject the request unless signature verification passes. A decoded but unverified token is just attacker-controlled text.

Verifier checklist

  • Use verify, not decode
  • Pin issuer to https://drusy.ai
  • Pin audience to your own origin
  • Pin algorithms to ES256
  • Use the JWKS URL so key rotation keeps working

createRemoteJWKSet caches keys and refetches when it sees an unknown kid.

express.js
import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://api.drusy.ai/public/signing-keys/<businessId>/jwks.json"),
);

async function verifyDrusyIdentity(req) {
  const { payload } = await jwtVerify(req.get("x-drusy-identity"), JWKS, {
    issuer: "https://drusy.ai",
    audience: "https://api.example.com",
    algorithms: ["ES256"],
  });

  return payload.sub;
}

app.post("/drusy/lookup-customer", async (req, res) => {
  let phone;
  try {
    phone = await verifyDrusyIdentity(req);
  } catch {
    return res.sendStatus(401);
  }

  // Look the customer up by phone.
});

Body hash

The optional bh claim is base64url SHA-256 of the raw request bytes. Compute it over bytes as received, not over re-serialized JSON.

body-hash.js
import crypto from "node:crypto";

const bh = crypto
  .createHash("sha256")
  .update(req.rawBody)
  .digest("base64url");

if (payload.bh && payload.bh !== bh) {
  return res.sendStatus(401);
}
Skipping bh still leaves you with an authentic phone number. Check it when you also need proof that the payload was not changed.

Am I actually verifying?

The action test sheet sends a normal request, then can send a second request with a structurally valid JWT whose signature has been corrupted.

Bad signature

Your endpoint should reject it.

Fake phone

The probe uses +10000000000, never a real customer.

Marked test

The request includes X-Drusy-Test: true for logs.

Rotation

Rotate from the dashboard or API. Drusy retires the current key and mints a new one immediately. Retired keys stay in JWKS so in-flight tokens keep verifying.

Where to do this in DrusyAISettings > Credentials > Rotate key
POST /identity/business/signing-key/rotate with SETTINGS_WRITE
JWKS users do not need a manual cutover. If you pinned a PEM, swap it after rotation.

Go-live and limits

Before you ship

  • Send customer identity is on only for endpoints you control
  • Your server verifies X-Drusy-Identity before any customer lookup
  • Verifier pins iss, aud, and algorithms
  • JWKS URL is configured with the right business id
  • Invalid-signature probe returns an error
  • Body hash check is added if you need payload binding

What this does not cover

  • An endpoint that decodes a JWT without verifying the signature
  • Replay inside the five-minute window at the same endpoint, unless you cache jti
  • Cross-action replay within one origin, unless you check act
  • Compromise of Drusy's own signing infrastructure

Put your customer conversations on autopilot

Join businesses using DrusyAI to sell more, answer questions accurately, and support customers instantly, across every channel and connected to the systems they already run on.