From 13a117dff149e608ac7638ee0ba23bfad2d6e988 Mon Sep 17 00:00:00 2001 From: Okeke Chinedu Emmanuel Date: Mon, 29 Jun 2026 07:12:31 +0100 Subject: [PATCH 1/3] Add authenticated test request helper --- src/utils/test/auth-request.utils.test.ts | 68 +++++++++++++++++++++++ src/utils/test/auth-request.utils.ts | 62 +++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/utils/test/auth-request.utils.test.ts create mode 100644 src/utils/test/auth-request.utils.ts diff --git a/src/utils/test/auth-request.utils.test.ts b/src/utils/test/auth-request.utils.test.ts new file mode 100644 index 0000000..717506e --- /dev/null +++ b/src/utils/test/auth-request.utils.test.ts @@ -0,0 +1,68 @@ +import express from 'express'; +import { Keypair } from '@stellar/stellar-base'; +import { buildAuthRequest } from './auth-request.utils'; +import { + requireStellarSignature, + type StellarSignedRequest, +} from '../../middlewares/stellar-signature.middleware'; + +function createSignatureTestApp() { + const app = express(); + app.use(express.json()); + + app.post( + '/protected', + requireStellarSignature(), + (req: StellarSignedRequest, res) => { + res.status(200).json({ signatureVerified: req.signatureVerified }); + } + ); + + app.delete( + '/protected', + requireStellarSignature(), + (req: StellarSignedRequest, res) => { + res.status(200).json({ signatureVerified: req.signatureVerified }); + } + ); + + return app; +} + +describe('buildAuthRequest', () => { + it('builds a POST request whose auth headers pass Stellar signature verification', async () => { + const walletKeypair = Keypair.random(); + const body = { displayName: 'Auth Helper POST' }; + + const res = await buildAuthRequest( + 'POST', + '/protected', + body, + walletKeypair, + { + app: createSignatureTestApp(), + } + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ signatureVerified: true }); + }); + + it('builds a DELETE request whose auth headers pass Stellar signature verification', async () => { + const walletKeypair = Keypair.random(); + const body = { reason: 'cleanup' }; + + const res = await buildAuthRequest( + 'DELETE', + '/protected', + body, + walletKeypair, + { + app: createSignatureTestApp(), + } + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ signatureVerified: true }); + }); +}); diff --git a/src/utils/test/auth-request.utils.ts b/src/utils/test/auth-request.utils.ts new file mode 100644 index 0000000..5b00f57 --- /dev/null +++ b/src/utils/test/auth-request.utils.ts @@ -0,0 +1,62 @@ +import type { Express } from 'express'; +import supertest from 'supertest'; +import type TestAgent from 'supertest/lib/agent'; +import type Test from 'supertest/lib/test'; +import { Keypair } from '@stellar/stellar-base'; +import { createHash } from 'crypto'; + +export type AuthRequestMethod = 'DELETE' | 'PATCH' | 'POST' | 'PUT'; + +export interface AuthRequestOptions { + timestamp?: string; + app?: Express; +} + +export interface AuthRequestHeaders extends Record { + 'x-wallet-address': string; + 'x-wallet-signature': string; + 'x-timestamp': string; +} + +function getDefaultApp(): Express { + // Lazily load the full application only when callers do not inject a test app. + // This keeps focused helper tests from compiling app-level dependencies. + return require('../../app').default as Express; +} + +function buildCanonicalMessage(body: unknown, timestamp: string): Buffer { + const bodyJson = JSON.stringify(body); + const payload = `${bodyJson}${timestamp}`; + return createHash('sha256').update(payload, 'utf8').digest(); +} + +export function buildAuthHeaders( + body: string | object | undefined, + walletKeypair: Keypair, + timestamp = Date.now().toString() +): AuthRequestHeaders { + const message = buildCanonicalMessage(body, timestamp); + const signature = walletKeypair.sign(message).toString('base64'); + + return { + 'x-wallet-address': walletKeypair.publicKey(), + 'x-wallet-signature': signature, + 'x-timestamp': timestamp, + }; +} + +export function buildAuthRequest( + method: AuthRequestMethod, + path: string, + body: string | object | undefined, + walletKeypair: Keypair, + options: AuthRequestOptions = {} +): Test { + const requestApp = options.app ?? getDefaultApp(); + const request = supertest(requestApp) as TestAgent; + const normalizedMethod = + method.toLowerCase() as Lowercase; + const headers = buildAuthHeaders(body, walletKeypair, options.timestamp); + + return request[normalizedMethod](path).set(headers).send(body); +} From 5f898cf7463e6cf3f50b29368b86cce729b99dd4 Mon Sep 17 00:00:00 2001 From: Okeke Chinedu Emmanuel Date: Mon, 29 Jun 2026 07:24:27 +0100 Subject: [PATCH 2/3] Add creator detail not-found response --- .../creator/creator-profile.service.ts | 27 ++++++++-- ...eator-detail-not-found.integration.test.ts | 34 ++++++++++++ src/modules/creators/creators.controllers.ts | 52 ++++++++++++++++--- src/modules/creators/creators.routes.ts | 40 ++++++++++++-- 4 files changed, 136 insertions(+), 17 deletions(-) create mode 100644 src/modules/creators/creator-detail-not-found.integration.test.ts diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts index 96977fd..331f889 100644 --- a/src/modules/creator/creator-profile.service.ts +++ b/src/modules/creator/creator-profile.service.ts @@ -25,7 +25,7 @@ function normalizeProfileLinks( return links; } - return links.map((link) => ({ + return links.map(link => ({ ...link, label: truncateString(link.label, CREATOR_PROFILE_LIMITS.linkLabel), url: normalizeSocialLinkUrl(link.url), @@ -39,7 +39,7 @@ function normalizeProfilePerks( return perks; } - return perks.map((perk) => ({ + return perks.map(perk => ({ ...perk, title: truncateString(perk.title, CREATOR_PROFILE_LIMITS.perkTitle), description: truncateString( @@ -58,6 +58,19 @@ function buildCreatorDetailCacheMissContext(creatorId: string) { }; } +export async function creatorProfileExists( + creatorId: string +): Promise { + const profile = await prisma.creatorProfile.findFirst({ + where: { + OR: [{ id: creatorId }, { handle: creatorId }], + }, + select: { id: true }, + }); + + return profile !== null; +} + /** * Reads a creator profile from the database. * @@ -110,7 +123,10 @@ export async function getCreatorProfile( let priceChange24h: number | null = null; if (snapshot) { - priceChange24h = compute24hPriceChange(snapshot.currentPrice, snapshot.price24hAgo); + priceChange24h = compute24hPriceChange( + snapshot.currentPrice, + snapshot.price24hAgo + ); } return { @@ -148,7 +164,10 @@ export async function upsertCreatorProfile( const normalizedPayload: UpsertCreatorProfileBody = { ...payload, displayName: payload.displayName - ? truncateString(payload.displayName, CREATOR_PROFILE_LIMITS.displayName) + ? truncateString( + payload.displayName, + CREATOR_PROFILE_LIMITS.displayName + ) : payload.displayName, bio: payload.bio ? truncateString(payload.bio, CREATOR_PROFILE_LIMITS.bio) diff --git a/src/modules/creators/creator-detail-not-found.integration.test.ts b/src/modules/creators/creator-detail-not-found.integration.test.ts new file mode 100644 index 0000000..6770750 --- /dev/null +++ b/src/modules/creators/creator-detail-not-found.integration.test.ts @@ -0,0 +1,34 @@ +import supertest from 'supertest'; +import { ErrorCode } from '../../constants/error.constants'; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + $disconnect: jest.fn(), + }, +})); + +jest.mock('../creator/creator-profile.service', () => ({ + creatorProfileExists: jest.fn().mockResolvedValue(false), + getCreatorProfile: jest.fn(), +})); + +describe('GET /api/v1/creators/:id — not found', () => { + it('returns 404 with the standard error shape for a non-existent creator', async () => { + const { default: app } = await import('../../app'); + + const res = await supertest(app).get( + '/api/v1/creators/non-existent-creator-for-404-test' + ); + + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ + success: false, + error: { + code: ErrorCode.NOT_FOUND, + message: expect.any(String), + }, + }); + expect(res.body.error.code).toBe('NOT_FOUND'); + expect(res.body.error.message).toMatch(/creator.*not found/i); + }); +}); diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index e7f5b11..d116973 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -9,6 +9,7 @@ import { mapPublicCreatorStats } from './creators.stats'; import { sendSuccess, sendValidationError, + sendNotFound, } from '../../utils/api-response.utils'; import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; import { parsePublicQuery } from '../../utils/public-query-parse.utils'; @@ -21,6 +22,10 @@ import { type FilterParseErrorCategory, } from '../../utils/filter-parse-metrics.utils'; import { parseCreatorId } from '../../utils/creator-id.utils'; +import { + creatorProfileExists, + getCreatorProfile, +} from '../creator/creator-profile.service'; /** * Controller for GET /api/v1/creators @@ -35,16 +40,18 @@ export const httpListCreators: AsyncController = async (req, res, next) => { warnIfUnrecognizedCreatorListSort(ctx.query, req.requestId); // Validate query parameters - const parsed = parsePublicQuery( - CreatorListQuerySchema, - ctx.query, - { debugContext: 'creator-list-query' } - ); + const parsed = parsePublicQuery(CreatorListQuerySchema, ctx.query, { + debugContext: 'creator-list-query', + }); if (!parsed.ok) { // Increment filter parse error counter const category = categorizeParseError(parsed.details); incrementFilterParseError('/api/v1/creators', category); - return sendValidationError(res, 'Invalid query parameters', parsed.details); + return sendValidationError( + res, + 'Invalid query parameters', + parsed.details + ); } const validatedQuery = parsed.data; @@ -87,7 +94,12 @@ function categorizeParseError( details: Array<{ field: string; message: string }> ): FilterParseErrorCategory { // Check for unknown key errors (strict mode violations) - if (details.some(d => d.message.includes('unrecognized') || d.message.includes('unknown'))) { + if ( + details.some( + d => + d.message.includes('unrecognized') || d.message.includes('unknown') + ) + ) { return 'unknown_key'; } // Default to invalid_value for type/range errors @@ -103,7 +115,9 @@ function categorizeParseError( export const httpGetCreatorStats: AsyncController = async (req, res, next) => { try { const rawId = req.params.id; - const _creatorId = parseCreatorId(Array.isArray(rawId) ? rawId[0] : rawId); + const _creatorId = parseCreatorId( + Array.isArray(rawId) ? rawId[0] : rawId + ); // TODO: Fetch actual creator metrics from database/service using _creatorId // For now, return placeholder data @@ -123,3 +137,25 @@ export const httpGetCreatorStats: AsyncController = async (req, res, next) => { next(error); } }; + +/** + * Controller for GET /api/v1/creators/:id + * + * Returns public profile details for a specific creator. + */ +export const httpGetCreator: AsyncController = async (req, res, next) => { + try { + const rawId = req.params.id; + const creatorId = Array.isArray(rawId) ? rawId[0] : rawId; + + if (!(await creatorProfileExists(creatorId))) { + return sendNotFound(res, 'Creator'); + } + + const profile = await getCreatorProfile(creatorId); + attachTimestampHeader(res); + sendSuccess(res, profile, 200, 'Creator retrieved successfully'); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/creators/creators.routes.ts b/src/modules/creators/creators.routes.ts index 6fde2be..2d503c4 100644 --- a/src/modules/creators/creators.routes.ts +++ b/src/modules/creators/creators.routes.ts @@ -1,5 +1,9 @@ import { Router } from 'express'; -import { httpListCreators, httpGetCreatorStats } from './creators.controllers'; +import { + httpListCreators, + httpGetCreator, + httpGetCreatorStats, +} from './creators.controllers'; import { httpGetCreatorHolders } from './creator-holders.controller'; import { cacheControl } from '../../middlewares/cache-control.middleware'; import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants'; @@ -24,7 +28,9 @@ creatorsRouter.use(normalizeTrailingSlash); creatorsRouter.get( '/', createCreatorReadMetricsMiddleware('list'), - cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.LIST]), + cacheControl( + CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.LIST] + ), httpListCreators ); // 405 handler for / @@ -42,7 +48,9 @@ creatorsRouter.get( '/:id/stats', validateCreatorParam('id'), createCreatorReadMetricsMiddleware('detail'), - cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_STATS]), + cacheControl( + CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_STATS] + ), httpGetCreatorStats ); // 405 handler for /:id/stats @@ -61,7 +69,9 @@ creatorsRouter.get( '/:id/holders', validateCreatorParam('id'), createCreatorReadMetricsMiddleware('holders'), - cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_HOLDERS]), + cacheControl( + CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_HOLDERS] + ), httpGetCreatorHolders ); // 405 handler for /:id/holders @@ -69,4 +79,24 @@ creatorsRouter.all('/:id/holders', (_req, res) => { res.set('Allow', 'GET').sendStatus(405); }); -export default creatorsRouter; \ No newline at end of file +/** + * GET /api/v1/creators/:id + * + * Get public details for a specific creator. + * Public endpoint with 5-minute cache. + */ +creatorsRouter.get( + '/:id', + validateCreatorParam('id'), + createCreatorReadMetricsMiddleware('detail'), + cacheControl( + CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_PROFILE] + ), + httpGetCreator +); +// 405 handler for /:id +creatorsRouter.all('/:id', (_req, res) => { + res.set('Allow', 'GET').sendStatus(405); +}); + +export default creatorsRouter; From 42210685f56656b60a296af704a2aec2aa63117d Mon Sep 17 00:00:00 2001 From: Okeke Chinedu Emmanuel Date: Mon, 29 Jun 2026 07:52:34 +0100 Subject: [PATCH 3/3] docs: add webhook signature verification guide --- docs/webhooks.md | 232 +++++++++++++++++++++++++++++++---------------- 1 file changed, 155 insertions(+), 77 deletions(-) diff --git a/docs/webhooks.md b/docs/webhooks.md index 121756e..7710b14 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -11,73 +11,77 @@ To register, view, or delete webhooks, creators must authenticate using Stellar ### Base Headers for Signed Creator Requests All management requests require the following headers for wallet verification: + - `x-wallet-address`: The Stellar public key (G...) of the creator's wallet. - `x-signature`: A Base64-encoded signature of the request payload, verifying wallet ownership. - `x-timestamp`: The Unix timestamp (in milliseconds) when the signature was generated. The signature is rejected if this timestamp is older than 5 minutes. ### Register a Webhook + - **Method:** `POST` - **Path:** `/api/v1/creators/:id/webhooks` - **Request Body (JSON):** - ```json - { - "callback_url": "https://your-domain.com/webhooks/trade-handler", - "events": ["buy", "sell"] - } - ``` + ```json + { + "callback_url": "https://your-domain.com/webhooks/trade-handler", + "events": ["buy", "sell"] + } + ``` - **Response (201 Created):** - ```json - { - "success": true, - "data": { - "id": "cm1a2b3c4d0000z9y8x7w6v5u4", - "creatorId": "webhook-test-creator-id", - "callbackUrl": "https://your-domain.com/webhooks/trade-handler", - "events": ["buy", "sell"], - "isActive": true, - "isFailing": false, - "createdAt": "2026-06-23T04:00:00.000Z", - "updatedAt": "2026-06-23T04:00:00.000Z" - }, - "message": "Webhook registered successfully" - } - ``` + ```json + { + "success": true, + "data": { + "id": "cm1a2b3c4d0000z9y8x7w6v5u4", + "creatorId": "webhook-test-creator-id", + "callbackUrl": "https://your-domain.com/webhooks/trade-handler", + "events": ["buy", "sell"], + "isActive": true, + "isFailing": false, + "createdAt": "2026-06-23T04:00:00.000Z", + "updatedAt": "2026-06-23T04:00:00.000Z" + }, + "message": "Webhook registered successfully" + } + ``` ### List Webhooks + - **Method:** `GET` - **Path:** `/api/v1/creators/:id/webhooks` - **Response (200 OK):** - ```json - { - "success": true, - "data": [ - { - "id": "cm1a2b3c4d0000z9y8x7w6v5u4", - "creatorId": "webhook-test-creator-id", - "callbackUrl": "https://your-domain.com/webhooks/trade-handler", - "events": ["buy", "sell"], - "isActive": true, - "isFailing": false, - "createdAt": "2026-06-23T04:00:00.000Z", - "updatedAt": "2026-06-23T04:00:00.000Z" - } - ] - } - ``` + ```json + { + "success": true, + "data": [ + { + "id": "cm1a2b3c4d0000z9y8x7w6v5u4", + "creatorId": "webhook-test-creator-id", + "callbackUrl": "https://your-domain.com/webhooks/trade-handler", + "events": ["buy", "sell"], + "isActive": true, + "isFailing": false, + "createdAt": "2026-06-23T04:00:00.000Z", + "updatedAt": "2026-06-23T04:00:00.000Z" + } + ] + } + ``` ### Delete a Webhook + - **Method:** `DELETE` - **Path:** `/api/v1/creators/:id/webhooks/:webhookId` - **Response (200 OK):** - ```json - { - "success": true, - "data": { - "id": "cm1a2b3c4d0000z9y8x7w6v5u4" - }, - "message": "Webhook deleted successfully" - } - ``` + ```json + { + "success": true, + "data": { + "id": "cm1a2b3c4d0000z9y8x7w6v5u4" + }, + "message": "Webhook deleted successfully" + } + ``` --- @@ -87,27 +91,27 @@ When a trade occurs, AccessLayer sends an HTTP `POST` request containing a JSON ### Payload Fields -| Field Name | Type | Description | Example Value | -| :--- | :--- | :--- | :--- | -| `event_type` | `string` | The type of trade event (`"buy"` or `"sell"`). | `"buy"` | -| `creator_id` | `string` | The Stellar public key or identifier of the creator whose keys were traded. | `"GCSW65D4G56DF...2XDF"` | -| `buyer_or_seller_address` | `string` | The Stellar public key of the trader's wallet executing the transaction. | `"GDD3DDK4J5H5D...8LKF"` | -| `amount` | `string` | The amount of keys traded (decimal representation). | `"1.0000000"` | -| `price` | `string` | The price per key in XLM (decimal representation). | `"15.5000000"` | -| `fee_paid` | `string` | The protocol/creator fee paid for this trade in XLM. | `"0.4650000"` | -| `timestamp` | `string` | The ISO 8601 UTC timestamp of the trade event transaction. | `"2026-06-23T04:00:00.000Z"` | +| Field Name | Type | Description | Example Value | +| :------------------------ | :------- | :-------------------------------------------------------------------------- | :--------------------------- | +| `event_type` | `string` | The type of trade event (`"buy"` or `"sell"`). | `"buy"` | +| `creator_id` | `string` | The Stellar public key or identifier of the creator whose keys were traded. | `"GCSW65D4G56DF...2XDF"` | +| `buyer_or_seller_address` | `string` | The Stellar public key of the trader's wallet executing the transaction. | `"GDD3DDK4J5H5D...8LKF"` | +| `amount` | `string` | The amount of keys traded (decimal representation). | `"1.0000000"` | +| `price` | `string` | The price per key in XLM (decimal representation). | `"15.5000000"` | +| `fee_paid` | `string` | The protocol/creator fee paid for this trade in XLM. | `"0.4650000"` | +| `timestamp` | `string` | The ISO 8601 UTC timestamp of the trade event transaction. | `"2026-06-23T04:00:00.000Z"` | ### Example Payload ```json { - "event_type": "buy", - "creator_id": "GCSW65D4G56DF8B2N7M9L3K4J2XDF", - "buyer_or_seller_address": "GDD3DDK4J5H5D9S8A7P6O5I4U8LKF", - "amount": "100.0000000", - "price": "10.5000000", - "fee_paid": "0.5000000", - "timestamp": "2026-06-23T04:00:00.000Z" + "event_type": "buy", + "creator_id": "GCSW65D4G56DF8B2N7M9L3K4J2XDF", + "buyer_or_seller_address": "GDD3DDK4J5H5D9S8A7P6O5I4U8LKF", + "amount": "100.0000000", + "price": "10.5000000", + "fee_paid": "0.5000000", + "timestamp": "2026-06-23T04:00:00.000Z" } ``` @@ -118,13 +122,15 @@ When a trade occurs, AccessLayer sends an HTTP `POST` request containing a JSON Integrators should be aware of the following delivery characteristics when handling webhooks: ### At-Least-Once Delivery + AccessLayer guarantees that all matching trade events are delivered **at least once** to your callback URL. However, under certain conditions (such as network hiccups, database latency, or retries), the same event might be sent multiple times. > [!TIP] > **Idempotency Handling:** Webhook consumers should check if an event has already been processed before taking action. Since event payloads do not currently include a unique event UUID, consumers can construct an idempotency key using a hash or combination of `timestamp`, `buyer_or_seller_address`, `amount`, and `price`. ### Delivery Ordering -Because webhook deliveries are handled asynchronously and retry delays can occur on a per-event basis, **delivery order is not strictly guaranteed**. + +Because webhook deliveries are handled asynchronously and retry delays can occur on a per-event basis, **delivery order is not strictly guaranteed**. > [!IMPORTANT] > **Event Chronology:** Consumers should inspect the `timestamp` field in the webhook payload to determine the actual chronological sequence of trade events, rather than relying on the order of HTTP request arrivals. @@ -139,23 +145,95 @@ If a delivery attempt fails, AccessLayer retries the delivery using an exponenti - **Maximum Attempts:** AccessLayer will attempt delivery up to **3 times** (the original dispatch plus 2 retries). - **Exponential Backoff:** The delay before retrying increases exponentially with each failed attempt, using the formula: $$\text{delay (ms)} = 2^{\text{attempt}} \times 1000$$ - - **Attempt 1 (Original):** Dispatched immediately. - - **Attempt 2 (Retry 1):** Delays **2 seconds** after Attempt 1 fails. - - **Attempt 3 (Retry 2):** Delays **4 seconds** after Attempt 2 fails. + - **Attempt 1 (Original):** Dispatched immediately. + - **Attempt 2 (Retry 1):** Delays **2 seconds** after Attempt 1 fails. + - **Attempt 3 (Retry 2):** Delays **4 seconds** after Attempt 2 fails. - **Exhaustion & Failure Flagging:** - - If all 3 attempts fail, the event status is updated to `FAILED` in the database, and the error description is stored in `lastError`. - - The parent webhook registration is updated with `isFailing = true`. - - **Suspension:** While a webhook is flagged as failing, future events will not be dispatched to it. This prevents unnecessary traffic to dead endpoints. Creators must delete and recreate the webhook (or update its status once the endpoint is resolved) to resume dispatches. + - If all 3 attempts fail, the event status is updated to `FAILED` in the database, and the error description is stored in `lastError`. + - The parent webhook registration is updated with `isFailing = true`. + - **Suspension:** While a webhook is flagged as failing, future events will not be dispatched to it. This prevents unnecessary traffic to dead endpoints. Creators must delete and recreate the webhook (or update its status once the endpoint is resolved) to resume dispatches. --- ## 5. Request Verification (Signature) -> [!WARNING] -> Outgoing webhook signature verification is **planned but not yet implemented**. +Creators should verify every incoming webhook before processing the payload. Verification confirms that the request was produced by AccessLayer and that the request body was not modified in transit. + +### Signature Headers + +AccessLayer includes the following headers on each outgoing webhook `POST` request: + +| Header | Description | +| :------------------------ | :----------------------------------------------------------------------- | +| `x-accesslayer-signature` | Hex-encoded HMAC digest for the request. | +| `x-accesslayer-timestamp` | Unix timestamp in milliseconds when AccessLayer generated the signature. | + +### Signing Algorithm and Signed Content + +AccessLayer computes the signature with **HMAC-SHA256** using the webhook signing secret associated with the webhook registration. + +The signed message is the exact timestamp header value, a period (`.`), and the exact raw HTTP request body bytes: + +```text +. +``` + +For example, if the timestamp header is `1782705600000`, the HMAC input is: + +```text +1782705600000.{"event_type":"buy","creator_id":"GCSW...","amount":"100.0000000"} +``` + +> [!IMPORTANT] +> Use the raw request body exactly as received on the wire. Do not parse and re-serialize JSON before verification, because whitespace or property-order changes will produce a different HMAC. + +### Step-by-Step Verification Guide + +1. Read `x-accesslayer-signature` and `x-accesslayer-timestamp` from the request headers. +2. Reject the request if either header is missing. +3. Parse `x-accesslayer-timestamp` as a Unix timestamp in milliseconds. +4. Reject the request if the timestamp is outside the allowed replay window. AccessLayer recommends accepting signatures only when the timestamp is within **5 minutes** of your server time. +5. Build the signed message as `timestamp + "." + rawBody`. +6. Compute `HMAC-SHA256(signedMessage, webhookSigningSecret)` and encode the digest as lowercase hexadecimal. +7. Compare the computed digest with `x-accesslayer-signature` using a constant-time comparison function. +8. Process the webhook only after both the timestamp and signature checks pass. + +### Pseudocode Example + +```pseudo +function handleAccessLayerWebhook(request): + signature = request.header("x-accesslayer-signature") + timestamp = request.header("x-accesslayer-timestamp") + rawBody = request.rawBody + + if signature is missing or timestamp is missing: + return response(status = 400) + + timestampMs = parseInteger(timestamp) + nowMs = currentUnixTimeMilliseconds() + + if timestampMs is invalid: + return response(status = 400) + + if absoluteValue(nowMs - timestampMs) > 5 minutes: + return response(status = 400) + + signedMessage = timestamp + "." + rawBody + expectedSignature = hex(hmacSha256(secret = webhookSigningSecret, message = signedMessage)) + + if not constantTimeEquals(expectedSignature, signature): + return response(status = 400) + + processWebhook(JSON.parse(rawBody)) + return response(status = 200) +``` + +### Replay Attack Protection + +The timestamp header is part of the signed message, so a third party cannot change it without invalidating the signature. Consumers should reject any request whose `x-accesslayer-timestamp` is more than **5 minutes** in the past or future relative to their server clock. This limits the time window in which a captured valid request can be replayed. + +For additional protection, consumers may store recently seen signatures or idempotency keys for the same 5-minute window and reject duplicates. This duplicate check is optional but recommended for high-risk integrations. -AccessLayer does not currently sign outgoing HTTP `POST` requests sent to callback URLs (no webhook secret or `x-signature` header is provided for verification). +### Verification Failures -### Recommendations for securing your callback endpoint in the interim: -1. **Obscure Endpoint Paths:** Use a secret query parameter or random path segment (e.g., `https://your-domain.com/webhooks/trade-handler-a7b8c9d0`) known only to your application and configured in the webhook's `callback_url`. -2. **Network Restrictions / Firewalls:** If possible, restrict incoming traffic to known IP ranges or hostnames of the AccessLayer server. +If any verification step fails, return **HTTP 400 Bad Request** and do **not** process the payload. Do not partially process failed requests, enqueue background work for them, or treat them as successful deliveries.