# Partner merchant approval notifications

Statira sends `merchant.approved` to a partner's signed HTTPS webhook and notification email when an administrator approves a linked merchant. Approval sets merchant approval and KYC to `APPROVED` and the account to `ACTIVE`.

The event includes the partner's `externalUserId` from onboarding, so the platform can update the corresponding seller. No KYC documents, passwords, API keys, or owner identity details are included. Notifications also apply to merchants linked through owner consent before approval. An existing approved merchant linked later does not generate a new approval event; retrieve its current state through the partner merchant lookup API.

## Configure destinations

Base URL: `https://api.paywithstatira.com` (production) or `https://api.dev.paywithstatira.com` (development).

Use the partner credentials issued for the target environment:

```http
PUT /api/v1/partners/notifications
X-Partner-Id: marketvillage
X-Partner-Key: <partner-api-key>
Content-Type: application/json

{
  "email": "<partner-notification-email>",
  "webhookUrl": "https://<partner-domain>/webhooks/statira/merchant-approval",
  "rotateSecret": false
}
```

Both destinations are required. The URL must use HTTPS, resolve to public addresses, and must not contain user credentials or a fragment. Redirects are not followed. Partners can configure only their own destinations. Administrators can configure the same settings from **Partners → Merchant approval notifications**.

The standard API envelope's `data` contains:

```json
{
  "email": "<partner-notification-email>",
  "webhookUrl": "https://<partner-domain>/webhooks/statira/merchant-approval",
  "configured": true,
  "signingSecret": "<copy-once-secret>"
}
```

Copy `signingSecret` on creation and keep it server-side. It is a separate signing key, not your partner API key. `GET /api/v1/partners/notifications` returns current settings with `signingSecret: null`. Normal updates preserve the key and also return null. Send `rotateSecret: true` to replace it; the new key is returned once and applies to subsequent deliveries, including retries. Coordinate rotations with your receiver.

## Webhook

```http
POST <configured-webhook-url>
Content-Type: application/json
X-Statira-Event-Id: 4a673f29-baa5-4522-ac9d-fc0fc013e42d
X-Statira-Timestamp: <Unix-seconds>
X-Statira-Signature: v1=<lowercase-hex-HMAC-SHA256>
```

```json
{
  "id": "4a673f29-baa5-4522-ac9d-fc0fc013e42d",
  "type": "merchant.approved",
  "occurredAt": "2026-09-25T12:00:00Z",
  "data": {
    "partnerId": "marketvillage",
    "externalUserId": "seller-123",
    "merchantId": "b858d7ee-3d84-442f-950a-fc9e83b76345",
    "businessName": "Example Business",
    "approvalStatus": "APPROVED",
    "kycStatus": "APPROVED",
    "status": "ACTIVE"
  }
}
```

The signature input is the timestamp header, a literal period, and the **exact raw request body**: `timestamp.rawBody`. Use the literal UTF-8 signing-secret string as the HMAC key; do not Base64-decode it. JSON key ordering is unspecified; never reserialize JSON before checking its signature.

Node.js / Express example:

```js
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();

// Register this raw-body route before any global express.json() middleware.
app.post('/webhooks/statira/merchant-approval', express.raw({ type: 'application/json' }), async (req, res) => {
  const timestamp = req.get('X-Statira-Timestamp') || '';
  const signature = req.get('X-Statira-Signature') || '';
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300 || !/^v1=[0-9a-f]{64}$/.test(signature)) {
    return res.sendStatus(401);
  }
  const expected = createHmac('sha256', process.env.STATIRA_APPROVAL_SIGNING_SECRET)
    .update(timestamp + '.').update(req.body).digest();
  const received = Buffer.from(signature.slice(3), 'hex');
  if (!timingSafeEqual(expected, received)) return res.sendStatus(401);
  let event;
  try { event = JSON.parse(req.body.toString('utf8')); }
  catch { return res.sendStatus(400); }
  if (event.id !== req.get('X-Statira-Event-Id') || event.type !== 'merchant.approved' || event.data?.partnerId !== process.env.STATIRA_PARTNER_ID) {
    return res.sendStatus(400);
  }
  // Implement this using your database: atomically insert a unique event.id
  // and update the seller mapped by BOTH partnerId and externalUserId.
  // An event already stored is a successful no-op.
  await recordApprovalOnce(event);
  return res.sendStatus(204);
});
```

Use a database unique constraint for event deduplication, rather than memory. Return 2xx only after durably accepting the event; any other status or timeout causes retries. Keep webhook handling fast (the sender times out after 10 seconds).

## Delivery behavior

- Notifications are durably queued in the merchant approval transaction and sent only after it commits. A failed email or webhook does not undo merchant approval.
- Each partner/merchant/external-user link gets one approval event. Repeated approval clicks and later account reactivation do not generate another.
- Email and webhook delivery are independent. A successful channel is not intentionally repeated when the other channel fails.
- Delivery is at least once: a crash or uncertain network response can cause duplicates. Deduplicate by the stable event ID. Retries retain the original body and event ID, with a fresh signed delivery timestamp.
- Retries use increasing delays from 30 seconds, capped at one hour. Events without configured destinations remain pending; saving settings makes pending events eligible immediately.
- Destination updates apply to pending deliveries. Notifications already delivered are not resent to a new destination.
- This applies to new approval events after deployment; historical approvals are not automatically replayed.
- The event describes the approval at `occurredAt`. For current merchant state, use `GET /api/v1/partners/merchants/{externalUserId}` with the same partner headers.
