feat/agentic-commerce-x402-local-sim
mdx 182 lines 6.9 KB
Raw
1 ---
2 title: "Receive and Verify Procurement Approval Webhooks Securely"
3 sidebarTitle: "Webhooks"
4 description: "Verify HMAC-signed Procurement Express callbacks and use approval decisions to resume or terminate your orchestration sessions in real time."
5 ---
6
7 When a procurement approval decision is made in Procurement Express, ProcureNet sends a signed HTTP callback to your registered endpoint. These webhooks let your backend react to approval outcomes in real time — resuming a suspended orchestration flow on approval or cleaning up session state on rejection. Every inbound callback includes an `x-procure-signature` HMAC header that you must verify before processing any payload.
8
9 ## Webhook Payload
10
11 Every callback sent to `POST /webhooks/procurement` contains the following fields:
12
13 <ResponseField name="session_id" type="string" required>
14 The ProcureNet session ID this decision applies to.
15 </ResponseField>
16
17 <ResponseField name="decision" type="string" required>
18 The approval outcome. One of `"approved"` or `"rejected"`.
19 </ResponseField>
20
21 <ResponseField name="actor" type="string" required>
22 The identifier of the user or system that made the approval decision.
23 </ResponseField>
24
25 <ResponseField name="reason" type="string">
26 An optional human-readable explanation for the decision. Populated on rejections and some approvals.
27 </ResponseField>
28
29 ## Setting Up Your Webhook Endpoint
30
31 <Steps>
32
33 ### Obtain Your Webhook Secret
34
35 Log in to ProcureNet settings and navigate to **Webhooks**. Generate or copy your webhook secret. Store it as an environment variable — never hard-code it in source.
36
37 ```bash
38 export WEBHOOK_SECRET=your_webhook_secret_here
39 ```
40
41 ### Register Your Endpoint URL
42
43 In ProcureNet settings, add your server's public URL as a Procurement Express callback target. ProcureNet will `POST` to this URL for every approval decision event.
44
45 ### Configure Raw Body Parsing
46
47 HMAC verification requires the **raw, unparsed request body bytes**. You must configure your framework to capture the raw body before any JSON parsing middleware runs. In Express, use `express.raw()` — not `express.json()` — for your webhook route.
48
49 <Warning>
50 Do not use `express.json()` (or any body-parsing middleware that parses JSON) on your webhook route. Parsing and re-serializing the body can alter field ordering or whitespace, causing your computed HMAC to differ from ProcureNet's. Use `express.raw({ type: 'application/json' })` to capture the raw bytes before any parsing occurs.
51 </Warning>
52
53 ```typescript
54 import express from 'express';
55
56 const app = express();
57
58 // Use raw body parsing ONLY for the webhook route
59 app.post(
60 '/webhooks/procurement',
61 express.raw({ type: 'application/json' }),
62 (req, res) => {
63 // req.body is now a Buffer containing the raw bytes
64 }
65 );
66 ```
67
68 ### Implement HMAC Verification
69
70 For every inbound request, compute the expected HMAC signature from the raw request body using your webhook secret and compare it to the `x-procure-signature` header. Reject any request where they do not match.
71
72 ```typescript
73 import { createHmac, timingSafeEqual } from 'crypto';
74
75 function verifySignature(rawBody: Buffer, signature: string, secret: string): boolean {
76 const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
77 const expectedBuf = Buffer.from(expected, 'utf8');
78 const signatureBuf = Buffer.from(signature, 'utf8');
79 if (expectedBuf.length !== signatureBuf.length) return false;
80 return timingSafeEqual(expectedBuf, signatureBuf);
81 }
82
83 app.post(
84 '/webhooks/procurement',
85 express.raw({ type: 'application/json' }),
86 (req, res) => {
87 const signature = req.headers['x-procure-signature'] as string;
88
89 if (!verifySignature(req.body as Buffer, signature, process.env.WEBHOOK_SECRET!)) {
90 return res.status(401).send('Invalid signature');
91 }
92
93 const payload = JSON.parse((req.body as Buffer).toString('utf8'));
94 const { session_id, decision, actor, reason } = payload;
95
96 if (decision === 'approved') {
97 // Resume the orchestration session
98 resumeSession(session_id);
99 } else if (decision === 'rejected') {
100 // Terminate session and surface the reason to the buyer
101 cancelSession(session_id, reason);
102 }
103
104 res.status(204).send();
105 }
106 );
107 ```
108
109 <Warning>
110 Always verify the `x-procure-signature` header before reading or acting on the payload. Never trust unverified callbacks — an attacker could spoof approval decisions against your endpoint.
111 </Warning>
112
113 ### Return HTTP 204 on Success
114
115 Respond with `204 No Content` after successfully processing the webhook. ProcureNet treats any non-2xx response as a failure and will retry delivery with exponential backoff.
116
117 </Steps>
118
119 ## Handling Decisions in Your Application
120
121 <Note>
122 An `"approved"` decision resumes a suspended orchestration flow — call `POST /api/v7/sessions/{id}/transition` with the appropriate event to continue. A `"rejected"` decision terminates the session; clean up any pending UI state and notify the buyer.
123 </Note>
124
125 Use the `decision` field to branch your handler logic. The complete handler combining verification and decision routing looks like this:
126
127 ```typescript
128 app.post(
129 '/webhooks/procurement',
130 express.raw({ type: 'application/json' }),
131 (req, res) => {
132 const signature = req.headers['x-procure-signature'] as string;
133
134 if (!verifySignature(req.body as Buffer, signature, process.env.WEBHOOK_SECRET!)) {
135 return res.status(401).send('Invalid signature');
136 }
137
138 const { session_id, decision, actor, reason } = JSON.parse(
139 (req.body as Buffer).toString('utf8')
140 );
141
142 if (decision === 'approved') {
143 resumeSession(session_id);
144 } else if (decision === 'rejected') {
145 cancelSession(session_id, reason);
146 }
147
148 res.status(204).send();
149 }
150 );
151 ```
152
153 ## Signature Verification Reference
154
155 The `x-procure-signature` header uses the format `sha256=<hex_digest>`. ProcureNet computes the HMAC-SHA256 of the raw JSON request body using your webhook secret. Your verification must use the **raw body bytes** — do not parse and re-serialize the JSON before computing the digest, as field ordering or whitespace differences will cause a mismatch. Use a timing-safe comparison to prevent timing-based attacks.
156
157 <CodeGroup>
158
159 ```typescript TypeScript
160 import { createHmac, timingSafeEqual } from 'crypto';
161
162 function verifySignature(rawBody: Buffer, signature: string, secret: string): boolean {
163 const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
164 const expectedBuf = Buffer.from(expected, 'utf8');
165 const signatureBuf = Buffer.from(signature, 'utf8');
166 if (expectedBuf.length !== signatureBuf.length) return false;
167 return timingSafeEqual(expectedBuf, signatureBuf);
168 }
169 ```
170
171 ```python Python
172 import hmac
173 import hashlib
174
175 def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
176 expected = 'sha256=' + hmac.new(
177 secret.encode(), raw_body, hashlib.sha256
178 ).hexdigest()
179 return hmac.compare_digest(expected, signature)
180 ```
181
182 </CodeGroup>