src/ns-webhook.ts (parses and runtime-narrows the webhook payload) and src/ns-webhook-verify.ts (verifies the Svix Ed25519 signature). The rule is: call verifyWebhookSignature() before parseWebhookPayload(). Reject forged requests before you trust the body. The Mustard demo Mini App is the reference example.
Status: signing scheme live. NS signs every webhook with the Svix scheme (Ed25519 over
svix-id / svix-timestamp / svix-signature headers) and carries the user address in the JSON body. Verification is implemented in src/ns-webhook-verify.ts and enforced in src/index.ts: webhooks failing verification get a 401. Set NS_JWKS_URL to enable it.What you get
- Receive NS subscription lifecycle webhooks: a user added/removed the Mini App, or enabled/disabled notifications.
- Verify each webhook’s Svix Ed25519 signature against the NS JWKS, so forged or tampered requests are rejected. During a key rotation the header may carry multiple signatures; the webhook is accepted if any signature matches any JWKS key.
- Send notifications to NS using the per-user token NS hands you through the webhook.
Registering your Mini App
Your Mini App is registered with Startale App automatically as soon as it callsready(): there is no need to call addMiniapp() (or any explicit “add” action). Calling sdk.actions.ready() signals that your app has finished loading and registers it with the host; from that point Startale App treats it as an installed Mini App and NS can start delivering the lifecycle webhooks documented below.
Mustard demo Mini App calls it once on mount, see src/App.tsx:
miniapp_added / notifications_enabled webhooks. That is where you receive the token. You never request the token directly; NS hands it to you through the webhook.
The webhook contract
NS sends a POST to a webhook URL you register with the Mini App manifest. Each request looks like:senderId: always present; identifies the subscription’s sender (Mini App). It is your Mini App’s origin: scheme + host only (e.g.https://your-miniapp.example.com). Any path or query string is trimmed, so per-language URL variants such as.../?lang=jaor.../jpall resolve to the samesenderId. This is the domain-level identity NS validatestargetUrlagainst when you send. It is still a URL string (with scheme), so URL-encode it if you ever pass it as a path parameter.userAddress: the user’s smart-account address, in the body. May be omitted if NS could not resolve it.
notificationDetails, two don’t:
notificationDetails.url is the fully-qualified NS send endpoint you POST to when delivering a push. Use it verbatim, do not derive or rewrite it. notificationDetails.token is the per-user token to include in that POST. Store both together when you receive miniapp_added / notifications_enabled; either can change on a token rotation.
Respond with HTTP 200 on success (any 2xx body; most teams use { "success": true }). Anything else makes NS retry: use 400 for malformed-body failures and 401 for signature failures so permanent errors don’t loop forever. Confirm the exact retry policy with the Startale team before depending on this.
Signature format
NS signs each webhook using the Svix scheme. Three headers carry the signature:svix-id: unique message id (e.g.msg_<unixnano>).svix-timestamp: unix seconds at send time.svix-signature: one or more space-separatedv1a,<signature>entries, where<signature>is a base64-standard (not base64url) encoded raw Ed25519 signature.
svix-signature carries multiple space-separated entries (v1a,<sigA> v1a,<sigB>); outside it, a single entry. Because more than one key may be in play, the receiver does not pin to a single key. It accepts the webhook if any signature verifies against any Ed25519 key published in the JWKS. (NS still publishes a kid per key, but it no longer selects the verifying key.)
The signed string is:
rawBody is the exact bytes of the JSON request body. Verify by:
- Fetching the NS JWKS (
NS_JWKS_URL) and collecting all its Ed25519 keys. - Reconstructing
toSignfrom the three values above (read the body as a raw string; re-serializing parsed JSON will not byte-match). - Accepting the webhook if any
v1asignature in the header verifies overtoSignagainst any of those public keys.
src/ns-webhook-verify.ts’s verifyWebhookSignature() does: it imports the JWKS key with jose’s importJWK and verifies with Node’s built-in crypto.
jose v6 importJWK returns a WebCrypto CryptoKey, which the helper converts via KeyObject.from() for crypto.verify.Install: files to copy
Copy both files from the Mustard demo Mini App repo into your backend’s source tree. Each is self-contained (no app imports), and the verifier reads no environment variables (you pass config in).Dependency
jose: used only to import the JWKS Ed25519 key. Ed25519 verification itself uses Node’s built-incrypto. No other runtime dependency. (ns-webhook.tsis pure TypeScript with no dependency at all.)
Configuration
The verifier module does not read
process.env itself; you read NS_JWKS_URL in your app and pass it as { jwksUrl }. This keeps the module portable across projects with different config conventions.
Wire-up
Framework-agnostic shape:401 signature gate runs before parsing; malformed bodies return 400:
Read the body as a raw string. Verification signs the exact bytes (
${svix-id}.${svix-timestamp}.${rawBody}); re-JSON.stringify-ing a parsed object would change key order/whitespace and break verification.API reference
parseWebhookPayload(rawBody: string): NsWebhookPayload
Lives in ns-webhook.ts. Parses the JSON body and narrows it to a typed discriminated union. Throws on:
- invalid JSON
- unknown
event - missing or wrong-typed
senderId - a present-but-non-string
userAddress - missing or wrong-typed fields in
notificationDetails(forminiapp_added/notifications_enabled)
verifyWebhookSignature(rawBody, headers, { jwksUrl }): Promise<void>
Lives in ns-webhook-verify.ts. Verifies the Svix Ed25519 signature; call it before parseWebhookPayload. Resolves on success, throws on any failure (missing headers, JWKS fetch error, no usable keys, or no signature matching any JWKS key); map a throw to a 401. Reads no env vars; pass jwksUrl in.
Sending a notification
To deliver a notification, POST to thenotificationDetails.url you stored (use it verbatim, do not derive or rewrite it) with the matching token(s). Mustard demo Mini App’s sendNotification() in src/index.ts is the reference; the request is a plain Content-Type: application/json POST:
targetUrl is the tap destination, gated to your own origin. Tapping Open Mini App opens your Mini App inline on the notifications screen, loaded at targetUrl, as long as its origin matches your registered Mini App. If there is no targetUrl, or its origin doesn’t match, the button falls back to the old behavior: opening your Mini App’s home. Because NS also rejects a mismatched targetUrl at send time (the token comes back in invalidTokens and the notification is never delivered to it), the app-side fallback is mainly a defense-in-depth backstop, not something to rely on for cross-origin links.targetUrl is a full URL you supply yourself, not the same mechanism as a shareable deep link (#appId<relativeUrl>). See that page for how the two compare.Content rules and rate limits are enforced by NS. Payloads that violate format requirements (title/body length, ALL CAPS, emoji count, exclamation marks) are rejected with
HTTP 400 before delivery. Tokens that exceed the per-user daily cap or fan-out limit are returned in rateLimitedTokens. See the Notification Policy for the full rules, permitted notification types, and frequency guidance.invalidTokens: the token is dead (user removed the Mini App / disabled notifications and you missed the webhook). Delete it from your store.rateLimitedTokens: NS throttled these; retry later with backoff, don’t hammer.successfulTokens: delivered.
Recalling a notification
You can recall a notification within 5 minutes of sending. POST to the same NS host as your send URL, at the path/api/v1/miniapp/delete-notification, with the notificationId you used on the send and the tokens you want to recall it from:
successfulTokens, invalidTokens, notificationTooOldTokens (the notification is past the 5-minute window), and notificationNotFoundTokens (no notification matched that notificationId for that token). Use it for misfire recovery (wrong cohort, content bug, typo caught immediately), not as a moderation tool. Like send, recall accepts up to 100 tokens per request. See the Notification Policy for the rules.
Production checklist
The signature-verification and parsing files are production-ready as-is; only the storage layer inindex.ts needs to be swapped for a real database.
Replay protection
Mustard demo Mini App does not enforcesvix-timestamp freshness (the verifier only checks the signature). For production, consider rejecting webhooks whose svix-timestamp is outside a tolerance window, plus svix-id dedupe, to harden against replay. NS sends a stable svix-id per message; (userAddress, event, token) also works as a dedupe key.
Local testing (ngrok)
NS calls your/webhook endpoint over the public internet and cannot reach localhost. To test the webhook flow, expose your backend through a tunnel such as ngrok (or any equivalent) and register the resulting public https://…/webhook URL with the manifest / Startale team. The Mustard demo Mini App example ships an ngrok service ready to go, see the repo README.
Testing without a live NS. Stand up a local JWKS server and sign test webhooks with a matching Ed25519 private key (mirror the ${id}.${ts}.${body} signing string, base64-standard encode the signature). Useful for exercising verifyWebhookSignature without a live NS.
Ask the Startale team for
- Retry policy on non-2xx responses (so you can size your idempotency window).
- The send-side request contract for delivering notifications to
notificationDetails.url.