Updated yesterday Build

Sahha x Convex Integration

Learn how to stream health data from Sahha to Convex using HTTP Actions and Webhooks. A step-by-step TypeScript guide with code examples.

In this guide you will stream health data from Sahha to Convex using Sahha Webhooks and Convex HTTP Actions (TypeScript) — choosing which data types to send, verifying webhooks securely, and persisting structured events in a Convex table.

Sahha SDK / Demo App → Sahha Platform → Webhooks → Convex HTTP Actions → Convex Database

Prerequisites

Before you begin, make sure you have:

Get data flowing into Sahha Required

Webhooks only fire when Sahha is actually receiving data. Choose how to connect:

Use the Sahha Demo App (fastest way to test)

  1. In the Sahha dashboard, go to Configure → Demo App
  2. Copy the Configuration URL (or scan the QR code)
  3. Install the Sahha Demo App from the App Store / Google Play
  4. In the demo app, paste the Configuration URL (or scan the QR) to connect it to your project
  5. Keep the demo app running so it can collect and submit device data

Once connected, you should see a new profile appear in your Sahha project and data beginning to flow.

If you’ve already integrated the Sahha SDK into your application, ensure that:

  • Your app is successfully streaming data to Sahha
  • You are setting a stable externalId when creating Sahha profiles (for example, your Convex user ID)

This externalId will later be sent back in webhooks as X-External-Id, which allows you to map Sahha events to the correct user in your database.

Set up your Convex project

Run npx convex dev to get started — a browser window will open for you to log in to your Convex account.

After logging in, the CLI will ask you to create a new project or choose an existing one. Pick whichever fits — the CLI handles all the setup either way.

Download the starter project — it includes an HTTP Action with signature verification, a schema definition, and all project configuration already set up.

Download starter project

After downloading:

  1. Unzip and open the project
  2. Install dependencies:
cd sahha-convex-webhooks-starter
npm install
  1. Start the Convex dev server:
npx convex dev

Convex will deploy your schema and functions automatically.

Create a new project

mkdir sahha-convex-webhooks
cd sahha-convex-webhooks
npm init -y
npm install convex

Initialize Convex

npx convex dev

This creates a convex/ directory and links your project. Press Ctrl+C to stop the dev server for now — we’ll restart it after adding the files.

Create the schema

Create convex/schema.ts:

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  sahhaEvents: defineTable({
    eventType: v.string(),
    externalId: v.string(),
    payload: v.any(),
    eventHash: v.string(),
    receivedAt: v.number(),
  })
    .index("by_event_hash", ["eventHash"])
    .index("by_external_id", ["externalId"])
    .index("by_event_type", ["eventType"]),
});

Create the webhook mutation

Create convex/webhooks.ts:

import { internalMutation } from "./_generated/server";
import { v } from "convex/values";

export const store = internalMutation({
  args: {
    eventType: v.string(),
    externalId: v.string(),
    payload: v.any(),
    eventHash: v.string(),
  },
  handler: async (ctx, args) => {
    const existing = await ctx.db
      .query("sahhaEvents")
      .withIndex("by_event_hash", (q) => q.eq("eventHash", args.eventHash))
      .first();

    if (existing) {
      return;
    }

    await ctx.db.insert("sahhaEvents", {
      ...args,
      receivedAt: Date.now(),
    });
  },
});

Create the HTTP action

Create convex/http.ts:

import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";

const encoder = new TextEncoder();

async function hmacSha256(
  secret: string,
  data: Uint8Array,
): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sig = await crypto.subtle.sign("HMAC", key, data);
  return [...new Uint8Array(sig)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

async function sha256Hex(data: Uint8Array): Promise<string> {
  const hash = await crypto.subtle.digest("SHA-256", data);
  return [...new Uint8Array(hash)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

const http = httpRouter();

http.route({
  path: "/sahha-webhook",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    try {
      const signature = (request.headers.get("x-signature") ?? "").trim();
      const eventType = (request.headers.get("x-event-type") ?? "").trim();
      const externalId = (request.headers.get("x-external-id") ?? "").trim();

      if (!signature) {
        return new Response("Missing X-Signature", { status: 400 });
      }

      const secret = process.env.SAHHA_WEBHOOK_SECRET ?? "";
      if (!secret) {
        return new Response("Missing SAHHA_WEBHOOK_SECRET", { status: 500 });
      }

      const rawBody = new Uint8Array(await request.arrayBuffer());

      const computed = await hmacSha256(secret, rawBody);
      if (computed !== signature) {
        return new Response("Invalid signature", { status: 401 });
      }

      const payload = JSON.parse(new TextDecoder().decode(rawBody));
      const eventHash = await sha256Hex(rawBody);

      await ctx.runMutation(internal.webhooks.store, {
        eventType: eventType || "unknown",
        externalId,
        payload,
        eventHash,
      });

      return new Response("ok", { status: 200 });
    } catch (err) {
      console.error("Webhook error:", err);
      return new Response("Server error", { status: 500 });
    }
  }),
});

export default http;

Now restart the dev server to deploy everything:

npx convex dev

Deploy and connect to Sahha

Store your webhook secret

Sahha signs every webhook request. Your HTTP Action must verify this signature.

  1. In Sahha, go to Data Delivery → Webhooks → Create webhook
  2. Copy the Secret value

Then in your terminal run:

npx convex env set SAHHA_WEBHOOK_SECRET your_secret

Replace your_secret with the actual secret you copied from Sahha.

To verify the secret was saved, run npx convex env list — you should see SAHHA_WEBHOOK_SECRET in the output. You can also check in the Convex dashboard → SettingsEnvironment Variables.

Deploy to production

npx convex deploy

After deployment, your webhook endpoint URL is:

https://<deployment>.convex.site/sahha-webhook

Convex dashboard Settings page showing the HTTP Actions URL

Use the .convex.site URL for HTTP Actions — not the .convex.cloud URL. You can find the correct URL in the Convex dashboard → SettingsGeneralHTTP Actions URL.

Convex gives you two deployments: Development (used by npx convex dev) and Production (used by npx convex deploy). Use your Development deployment URL while testing, then switch to your Production URL in the Sahha dashboard when you go live.

Configure Sahha

Go to Sahha Dashboard → Data Delivery → Webhooks → Create webhook and fill in:

Sahha dashboard Data Delivery Webhooks page

Click Create webhook.

Test the webhook

In the Sahha dashboard, find your webhook and click Actions → Test. This sends a real signed test payload to your endpoint.

Sahha webhook list with Actions menu and Test button highlighted

To verify it worked, go to the Convex dashboard → FunctionsPOST /sahha-webhook and check the Logs tab — you should see a successful function call.

How data works in Convex

Events are stored in the sahhaEvents table with five fields: eventType, externalId, payload, eventHash, and receivedAt. Deduplication is handled transactionally — before each insert, the mutation queries the by_event_hash index and skips the write if the event already exists.

Here’s an example of a biomarker event payload that Sahha sends via webhook:

BiomarkerCreatedIntegrationEvent 200
JSON
{
  "id": "1cb680dd-7e67-553c-9a29-c1bc066f6eb7",
  "type": "activity_low_intensity_duration",
  "unit": "minute",
  "value": "345",
  "version": 1,
  "category": "activity",
  "accountId": "4e9357a7-6275-4b22-b0f8-524962e4c7f7",
  "profileId": "b27fac52-0ae6-49d5-97c4-d720b7d6be16",
  "valueType": "long",
  "externalId": "SahhaInternalTestingProfile",
  "aggregation": "total",
  "endDateTime": "2026-02-11T23:59:59Z",
  "periodicity": "daily",
  "createdAtUtc": "2026-02-11T04:00:22.724731Z",
  "startDateTime": "2026-02-11T00:00:00Z"
}
Sahha sends an X-External-Id header with every webhook. Set this to your Convex user ID when creating Sahha profiles so you can map events back to the correct user in your database.

Troubleshooting

Webhook requests aren't reaching Convex +

Check:

  • You deployed the functions to the correct Convex project (npx convex deploy)
  • You copied the correct HTTP Action URL from the Convex dashboard
  • Your Sahha webhook Destination URL matches that exact URL (ending in /sahha-webhook)
401 Invalid signature +

This means your secret in Convex does not match Sahha.

Fix:

npx convex env set SAHHA_WEBHOOK_SECRET your_secret
npx convex deploy
No data in the database +
  • Confirm Sahha is actually receiving data (Demo App or SDK active)
  • Check function logs in the Convex dashboard → Logs
  • Verify the environment variable is set: run npx convex env list

Next steps

You now have a secure, production-ready Sahha webhook endpoint in Convex with automatic streaming into the Convex database.