From ac1b92b5027b70aeab3343ac8656752c0f64bdc5 Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Fri, 29 May 2026 01:21:26 +0530 Subject: [PATCH 1/8] fix: use crypto.verify(null) instead of createVerify for Ed25519 --- examples/create-checkout.ts | 2 +- packages/scrawn/src/core/webhook/index.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/create-checkout.ts b/examples/create-checkout.ts index f0c9dd5..59650dd 100644 --- a/examples/create-checkout.ts +++ b/examples/create-checkout.ts @@ -1,6 +1,6 @@ import { biller } from "./scrawn/biller.ts"; -const userId = "c0971bcb-b901-4c3e-a191-c9a97871c39f"; +const userId = "c0971bcb-b901-4c3e-a191-c9a97871c3f0"; const url = await biller.collectPayment(userId); diff --git a/packages/scrawn/src/core/webhook/index.ts b/packages/scrawn/src/core/webhook/index.ts index cb39a06..2a0081f 100644 --- a/packages/scrawn/src/core/webhook/index.ts +++ b/packages/scrawn/src/core/webhook/index.ts @@ -1,4 +1,4 @@ -import { createVerify } from "node:crypto"; +import { createPublicKey, verify } from "node:crypto"; import { parseEventType } from "./types.js"; import type { WebhookEvent } from "./types.js"; @@ -50,10 +50,13 @@ function verifyEd25519( publicKeyPem: string ): boolean { try { - const verifier = createVerify("ed25519"); - verifier.update(payload); - verifier.end(); - return verifier.verify(publicKeyPem, signatureBase64, "base64"); + const publicKey = createPublicKey(publicKeyPem); + return verify( + null, + Buffer.from(payload), + publicKey, + Buffer.from(signatureBase64, "base64") + ); } catch { return false; } From 4783b3b21af6e25d59e41de2b2c6ec35d8ff8c80 Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Fri, 29 May 2026 01:31:14 +0530 Subject: [PATCH 2/8] fix: add userId, billed_upto, createdAt to webhook types --- examples/ai-token-stream-expr-usage.ts | 2 +- examples/create-checkout.ts | 2 +- packages/scrawn/src/core/webhook/types.ts | 18 +++++------------- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/examples/ai-token-stream-expr-usage.ts b/examples/ai-token-stream-expr-usage.ts index 6316c55..15279ae 100644 --- a/examples/ai-token-stream-expr-usage.ts +++ b/examples/ai-token-stream-expr-usage.ts @@ -11,7 +11,7 @@ config({ path: ".env.local" }); async function* tokenUsageFromAIStream(): AsyncGenerator< AITokenUsagePayload<"PREMIUM_CALL" | "EXTRA_FEE"> > { - const userId = "c0971bcb-b901-4c3e-a191-c9a97871c39f"; + const userId = "c0971bcb-b901-4c3e-a191-c9a97871c30f"; yield { userId, diff --git a/examples/create-checkout.ts b/examples/create-checkout.ts index 59650dd..a073f9b 100644 --- a/examples/create-checkout.ts +++ b/examples/create-checkout.ts @@ -1,6 +1,6 @@ import { biller } from "./scrawn/biller.ts"; -const userId = "c0971bcb-b901-4c3e-a191-c9a97871c3f0"; +const userId = "c0971bcb-b901-4c3e-a191-c9a97871c30f"; const url = await biller.collectPayment(userId); diff --git a/packages/scrawn/src/core/webhook/types.ts b/packages/scrawn/src/core/webhook/types.ts index 1a3add1..de080e1 100644 --- a/packages/scrawn/src/core/webhook/types.ts +++ b/packages/scrawn/src/core/webhook/types.ts @@ -1,15 +1,20 @@ export interface PaymentSucceededData { paymentId: string; checkoutSessionId: string; + userId: string; amount: number; currency: string; mode: "test" | "production"; + billed_upto: string; + createdAt: string; } export interface PaymentFailedData { paymentId: string; checkoutSessionId: string; + userId: string; mode: "test" | "production"; + createdAt: string; } export type WebhookEvent = @@ -28,19 +33,6 @@ export type WebhookEvent = data: PaymentFailedData; }; -export type WebhookEventMap = { - "payment.succeeded": { - resource: "payment"; - action: "succeeded"; - data: PaymentSucceededData; - }; - "payment.failed": { - resource: "payment"; - action: "failed"; - data: PaymentFailedData; - }; -}; - export function parseEventType( type: string ): { resource: string; action: string } | null { From 1a9f2c97ac6de8387c410216df0e696eba176578 Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Fri, 29 May 2026 01:37:54 +0530 Subject: [PATCH 3/8] chore: remove example webhook files --- examples/ai-token-stream-expr-usage.ts | 2 +- examples/create-checkout.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/ai-token-stream-expr-usage.ts b/examples/ai-token-stream-expr-usage.ts index 15279ae..a636f47 100644 --- a/examples/ai-token-stream-expr-usage.ts +++ b/examples/ai-token-stream-expr-usage.ts @@ -11,7 +11,7 @@ config({ path: ".env.local" }); async function* tokenUsageFromAIStream(): AsyncGenerator< AITokenUsagePayload<"PREMIUM_CALL" | "EXTRA_FEE"> > { - const userId = "c0971bcb-b901-4c3e-a191-c9a97871c30f"; + const userId = "c0971bcb-b901-4c3e-a191-c9a97871c36f"; yield { userId, diff --git a/examples/create-checkout.ts b/examples/create-checkout.ts index a073f9b..d3ec8a7 100644 --- a/examples/create-checkout.ts +++ b/examples/create-checkout.ts @@ -1,6 +1,6 @@ import { biller } from "./scrawn/biller.ts"; -const userId = "c0971bcb-b901-4c3e-a191-c9a97871c30f"; +const userId = "c0971bcb-b901-4c3e-a191-c9a97871c36f"; const url = await biller.collectPayment(userId); From b04a7e95108eb367bdd4a5ad910abd02283c7e4d Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Fri, 29 May 2026 01:38:52 +0530 Subject: [PATCH 4/8] chore: remove example webhook files from git and disk --- examples/create-checkout.ts | 9 ------- examples/webhook-server.ts | 49 ------------------------------------- 2 files changed, 58 deletions(-) delete mode 100644 examples/create-checkout.ts delete mode 100644 examples/webhook-server.ts diff --git a/examples/create-checkout.ts b/examples/create-checkout.ts deleted file mode 100644 index d3ec8a7..0000000 --- a/examples/create-checkout.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { biller } from "./scrawn/biller.ts"; - -const userId = "c0971bcb-b901-4c3e-a191-c9a97871c36f"; - -const url = await biller.collectPayment(userId); - -console.log(`\nCheckout URL for ${userId}:`); -console.log(url); -console.log(); diff --git a/examples/webhook-server.ts b/examples/webhook-server.ts deleted file mode 100644 index 2c67662..0000000 --- a/examples/webhook-server.ts +++ /dev/null @@ -1,49 +0,0 @@ -import express from "express"; -import { biller } from "./scrawn/biller.ts"; -import { toWebRequest } from "@scrawn/core"; - -const app = express(); - -app.post( - "/webhooks/scrawn", - express.raw({ type: "application/json" }), - async (req, res) => { - try { - // Convert Express req to Web API Request & verify in one call - const event = await biller.webhook(toWebRequest(req, req.body)); - - console.log(`\n=== ${event.resource}.${event.action} ===`); - console.log("ID:", event.id); - console.log("Data:", JSON.stringify(event.data, null, 2)); - console.log("========================\n"); - - // Handle based on event type — fully typed with intellisense - switch (event.action) { - case "succeeded": - // event.data.amount → number - // event.data.currency → "usd" - // event.data.mode → "test" | "production" - break; - case "failed": - // event.data.mode → "test" | "production" - break; - } - - res.status(200).json({ received: true }); - } catch (error) { - console.error("Webhook verification failed:", error); - res.status(401).json({ error: "Invalid signature" }); - } - } -); - -app.listen(3000, () => { - console.log("Webhook receiver listening on http://localhost:3000"); - console.log("Register this URL with Scrawn:"); - console.log( - ` curl -X POST http://localhost:8070/api/v1/internals/webhook-endpoint \\` - ); - console.log(` -H "Authorization: Bearer " \\`); - console.log(` -H "Content-Type: application/json" \\`); - console.log(` -d '{"url": "http://localhost:3000/webhooks/scrawn"}'`); -}); From ce00b3ef5ca73ee2a7fb1f369b7bd40bd4a89421 Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Fri, 29 May 2026 01:45:43 +0530 Subject: [PATCH 5/8] chore: remove dead verify.ts file, unused apiKeyAuth exports --- packages/scrawn/src/core/auth/apiKeyAuth.ts | 4 +- packages/scrawn/src/core/webhook/verify.ts | 120 -------------------- 2 files changed, 2 insertions(+), 122 deletions(-) delete mode 100644 packages/scrawn/src/core/webhook/verify.ts diff --git a/packages/scrawn/src/core/auth/apiKeyAuth.ts b/packages/scrawn/src/core/auth/apiKeyAuth.ts index aa69b66..f766d24 100644 --- a/packages/scrawn/src/core/auth/apiKeyAuth.ts +++ b/packages/scrawn/src/core/auth/apiKeyAuth.ts @@ -16,7 +16,7 @@ const API_KEY_REGEX = /^scrn_(dash|live|test)_[a-zA-Z0-9]{32}$/; /** * Type guard to validate API key format */ -export function isValidApiKey(key: string): key is ApiKeyFormat { +function isValidApiKey(key: string): key is ApiKeyFormat { return API_KEY_REGEX.test(key); } @@ -24,7 +24,7 @@ export function isValidApiKey(key: string): key is ApiKeyFormat { * Validates and returns a properly typed API key * @throws Error if the API key format is invalid */ -export function validateApiKey(key: string): ApiKeyFormat { +function validateApiKey(key: string): ApiKeyFormat { if (!isValidApiKey(key)) { log.error(`Invalid API key format: "${key}".`); throw new ScrawnValidationError( diff --git a/packages/scrawn/src/core/webhook/verify.ts b/packages/scrawn/src/core/webhook/verify.ts deleted file mode 100644 index a09e462..0000000 --- a/packages/scrawn/src/core/webhook/verify.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { createVerify } from "node:crypto"; - -export class WebhookVerificationError extends Error { - constructor(message: string) { - super(message); - this.name = "WebhookVerificationError"; - } -} - -export interface WebhookHeaders { - webhookId: string; - webhookTimestamp: string; - webhookSignature: string; -} - -export function extractWebhookHeaders( - headers: Headers | Record -): WebhookHeaders { - const get = (key: string): string | undefined => { - if (headers instanceof Headers) { - const val = headers.get(key); - return val ?? undefined; - } - const val = (headers as Record)[key]; - if (Array.isArray(val)) return val[0]; - return val; - }; - - const webhookId = get("webhook-id"); - const webhookTimestamp = get("webhook-timestamp"); - const webhookSignature = get("webhook-signature"); - - if (!webhookId) { - throw new WebhookVerificationError("Missing webhook-id header"); - } - if (!webhookTimestamp) { - throw new WebhookVerificationError("Missing webhook-timestamp header"); - } - if (!webhookSignature) { - throw new WebhookVerificationError("Missing webhook-signature header"); - } - - return { webhookId, webhookTimestamp, webhookSignature }; -} - -function publicKeyPrefixedToPem(prefixedKey: string): string { - const base64Key = prefixedKey.replace("whpk_", ""); - return `-----BEGIN PUBLIC KEY-----\n${base64Key}\n-----END PUBLIC KEY-----`; -} - -function verifyEd25519( - payload: string, - signatureBase64: string, - publicKeyPem: string -): boolean { - try { - const verifier = createVerify("ed25519"); - verifier.update(payload); - verifier.end(); - return verifier.verify(publicKeyPem, signatureBase64, "base64"); - } catch { - return false; - } -} - -export interface VerificationResult { - id: string; - timestamp: string; - rawBody: string; -} - -export function verifyWebhookSignature( - rawBody: string, - headers: WebhookHeaders, - publicKeyPrefixed: string, - toleranceSeconds: number = 300 -): VerificationResult { - const { webhookId, webhookTimestamp, webhookSignature } = headers; - - if (webhookSignature.startsWith("v1a,")) { - const signature = webhookSignature.slice("v1a,".length); - const signedPayload = `${webhookId}.${webhookTimestamp}.${rawBody}`; - const publicKeyPem = publicKeyPrefixedToPem(publicKeyPrefixed); - - const isValid = verifyEd25519(signedPayload, signature, publicKeyPem); - - if (!isValid) { - throw new WebhookVerificationError("Invalid webhook signature"); - } - } else { - throw new WebhookVerificationError( - `Unsupported signature version: expected v1a, got ${ - webhookSignature.split(",")[0] - }` - ); - } - - const now = Math.floor(Date.now() / 1000); - const timestamp = parseInt(webhookTimestamp, 10); - - if (Number.isNaN(timestamp)) { - throw new WebhookVerificationError( - "Invalid webhook-timestamp: not a number" - ); - } - - if (Math.abs(now - timestamp) > toleranceSeconds) { - throw new WebhookVerificationError( - `Webhook timestamp is outside tolerance (${Math.abs( - now - timestamp - )}s > ${toleranceSeconds}s)` - ); - } - - return { - id: webhookId, - timestamp: new Date(timestamp * 1000).toISOString(), - rawBody, - }; -} From f91f785fa7c6794c5deeb9a1ee697840ece89a27 Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Fri, 29 May 2026 11:43:08 +0530 Subject: [PATCH 6/8] fix: derive HTTP URL from baseURL config instead of hardcoded localhost --- packages/scrawn/src/core/scrawn.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/scrawn/src/core/scrawn.ts b/packages/scrawn/src/core/scrawn.ts index 62f0a1a..f79011f 100644 --- a/packages/scrawn/src/core/scrawn.ts +++ b/packages/scrawn/src/core/scrawn.ts @@ -135,6 +135,9 @@ export class Scrawn< return this.apiKey; } + /** Base URL for the HTTP API (derived from baseURL config) */ + private httpUrl: string; + private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -223,6 +226,7 @@ export class Scrawn< this.apiKey = config.apiKey; this.retryCount = config.retryCount ?? 2; + this.httpUrl = this.buildHttpUrl(config.baseURL); this.grpcClient = new GrpcClient(this.parseURLToTarget(config.baseURL), { secure: config.secure ?? true, credentials: config.credentials, @@ -245,6 +249,16 @@ export class Scrawn< : `${baseURL}:${ScrawnConfig.grpc.defaultPort}`; } + private buildHttpUrl(baseURL: string): string { + if (baseURL.includes("://")) { + const url = new URL(baseURL); + return `http://${url.hostname}:8070`; + } + + const host = baseURL.includes(":") ? baseURL.split(":")[0] : baseURL; + return `http://${host}:8070`; + } + /** * Create a type-safe tag reference. * @@ -1289,7 +1303,7 @@ export class Scrawn< async webhook(request: Request): Promise { if (!this.cachedPublicKey) { const response = await fetch( - "http://localhost:8070/api/v1/internals/webhook-endpoint/public-key", + `${this.httpUrl}/api/v1/internals/webhook-endpoint/public-key`, { headers: { Authorization: `Bearer ${this.apiKey}` } } ); if (!response.ok) From ee9a8eb394de79a0e6c0ae86316fb953203bb59b Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Fri, 29 May 2026 13:32:25 +0530 Subject: [PATCH 7/8] feat: add raw_data types and pass-through for Dodo webhook payload --- packages/scrawn/src/core/webhook/index.ts | 14 +++++++++--- packages/scrawn/src/core/webhook/types.ts | 28 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/scrawn/src/core/webhook/index.ts b/packages/scrawn/src/core/webhook/index.ts index 2a0081f..5cf71c8 100644 --- a/packages/scrawn/src/core/webhook/index.ts +++ b/packages/scrawn/src/core/webhook/index.ts @@ -114,7 +114,11 @@ export async function verifyWebhook( 300 ); - let parsed: { type: string; data: Record }; + let parsed: { + type: string; + data: Record; + raw_data?: Record; + }; try { parsed = JSON.parse(rawBody); } catch { @@ -134,6 +138,10 @@ export async function verifyWebhook( ); } + const eventData = parsed.raw_data + ? { ...parsed.data, raw_data: parsed.raw_data } + : parsed.data; + switch (parsed.type) { case "payment.succeeded": return { @@ -141,7 +149,7 @@ export async function verifyWebhook( timestamp, resource: "payment" as const, action: "succeeded" as const, - data: parsed.data, + data: eventData, } as unknown as WebhookEvent; case "payment.failed": return { @@ -149,7 +157,7 @@ export async function verifyWebhook( timestamp, resource: "payment" as const, action: "failed" as const, - data: parsed.data, + data: eventData, } as unknown as WebhookEvent; default: throw new WebhookVerificationError(`Unknown event type: ${parsed.type}`); diff --git a/packages/scrawn/src/core/webhook/types.ts b/packages/scrawn/src/core/webhook/types.ts index de080e1..ede025a 100644 --- a/packages/scrawn/src/core/webhook/types.ts +++ b/packages/scrawn/src/core/webhook/types.ts @@ -1,3 +1,29 @@ +export interface DodoPaymentData { + id: string; + payment_id: string; + checkout_session_id: string; + total_amount: number; + currency: string; + business_id: string; + status: string; + customer?: Record; + [key: string]: unknown; +} + +export interface DodoPaymentSucceededEvent { + business_id: string; + data: DodoPaymentData; + timestamp: string; + type: "payment.succeeded"; +} + +export interface DodoPaymentFailedEvent { + business_id: string; + data: Record; + timestamp: string; + type: "payment.failed"; +} + export interface PaymentSucceededData { paymentId: string; checkoutSessionId: string; @@ -7,6 +33,7 @@ export interface PaymentSucceededData { mode: "test" | "production"; billed_upto: string; createdAt: string; + raw_data: DodoPaymentSucceededEvent; } export interface PaymentFailedData { @@ -15,6 +42,7 @@ export interface PaymentFailedData { userId: string; mode: "test" | "production"; createdAt: string; + raw_data: DodoPaymentFailedEvent; } export type WebhookEvent = From f5d433fb87f2dc8a82ae9ba10aa1a281bf8b7fc4 Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Fri, 29 May 2026 13:42:32 +0530 Subject: [PATCH 8/8] fix: full intellisense for Dodo webhook types (CustomerLimitedDetails, BillingAddress, IntentStatus) --- packages/scrawn/src/core/webhook/types.ts | 59 ++++++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/scrawn/src/core/webhook/types.ts b/packages/scrawn/src/core/webhook/types.ts index ede025a..14ce679 100644 --- a/packages/scrawn/src/core/webhook/types.ts +++ b/packages/scrawn/src/core/webhook/types.ts @@ -1,13 +1,58 @@ +export interface BillingAddress { + country: string; + city?: string | null; + state?: string | null; + street?: string | null; + zipcode?: string | null; +} + +export interface CustomerLimitedDetails { + customer_id: string; + email: string; + name: string; + phone_number?: string | null; + metadata?: { [key: string]: string }; +} + +export type IntentStatus = + | "succeeded" + | "failed" + | "cancelled" + | "processing" + | "requires_customer_action" + | "requires_merchant_action" + | "requires_payment_method" + | "requires_confirmation" + | "requires_capture" + | "partially_captured" + | "partially_captured_and_capturable"; + export interface DodoPaymentData { - id: string; + billing: BillingAddress; + brand_id: string; + business_id: string; + created_at: string; + currency: string; + customer: CustomerLimitedDetails; + digital_products_delivered: boolean; + metadata: { [key: string]: string }; payment_id: string; - checkout_session_id: string; total_amount: number; - currency: string; - business_id: string; - status: string; - customer?: Record; - [key: string]: unknown; + status?: IntentStatus | null; + subscription_id?: string | null; + tax?: number | null; + updated_at?: string | null; + invoice_id?: string | null; + invoice_url?: string | null; + payment_method?: string | null; + payment_method_type?: string | null; + discount_id?: string | null; + discount_ids?: string[] | null; + product_cart?: Array<{ + product_id: string; + quantity: number; + }>; + refund_status?: string | null; } export interface DodoPaymentSucceededEvent {