|
1
|
--- |
|
2
|
title: "POST /webhooks/procurement — Approval Callback" |
|
3
|
sidebarTitle: "POST /webhooks" |
|
4
|
description: "Handle signed Procurement Express approval webhooks. Approved decisions resume sessions; rejected ones terminate them. Always verify the HMAC signature." |
|
5
|
--- |
|
6
|
|
|
7
|
ProcureNet sends a signed POST request to your webhook URL whenever a Procurement Express approval decision is made. Your handler receives a payload containing a `session_id`, a `decision` of either `approved` or `rejected`, the `actor` who made the decision, and an optional `reason`. Before acting on the payload, you must verify the `x-procure-signature` header using your shared webhook secret — requests that fail signature verification must be rejected to prevent spoofed approvals from influencing live procurement sessions. Return HTTP `204` on success; ProcureNet will retry on any non-`2xx` response. |
|
8
|
|
|
9
|
## Endpoint |
|
10
|
|
|
11
|
```http |
|
12
|
POST /webhooks/procurement |
|
13
|
``` |
|
14
|
|
|
15
|
## Authentication |
|
16
|
|
|
17
|
ProcureNet signs each outbound webhook request with an HMAC-SHA256 signature derived from your shared secret and the raw request body. The signature arrives in the `x-procure-signature` header in the format: |
|
18
|
|
|
19
|
```text |
|
20
|
x-procure-signature: sha256=<hex_digest> |
|
21
|
``` |
|
22
|
|
|
23
|
Your handler must recompute the expected signature and compare it to the header value **before** parsing or acting on the payload. |
|
24
|
|
|
25
|
<Warning> |
|
26
|
Always parse the request body using `express.raw()` (or equivalent raw body middleware) **before** performing signature verification. If you parse the body as JSON first, the serialization may differ from what ProcureNet signed, causing every verification to fail regardless of key validity. |
|
27
|
</Warning> |
|
28
|
|
|
29
|
## Signature Verification |
|
30
|
|
|
31
|
<Steps> |
|
32
|
<Step title="Receive the raw request body"> |
|
33
|
Configure your route to buffer the raw bytes of the request body. In Express, use `express.raw({ type: 'application/json' })` on the webhook route — do not apply `express.json()` globally before this route. |
|
34
|
</Step> |
|
35
|
<Step title="Read the x-procure-signature header"> |
|
36
|
Extract the `x-procure-signature` header from the incoming request. If the header is absent, reject the request immediately with `401`. |
|
37
|
</Step> |
|
38
|
<Step title="Recompute the expected signature"> |
|
39
|
Use HMAC-SHA256 with your `WEBHOOK_SECRET` and the raw request body buffer to compute the expected digest. Prepend `sha256=` to match the header format. |
|
40
|
</Step> |
|
41
|
<Step title="Compare signatures"> |
|
42
|
Compare the computed signature to the header value. Use a constant-time comparison where possible to avoid timing attacks. Reject with `401` on mismatch. |
|
43
|
</Step> |
|
44
|
<Step title="Parse and handle the payload"> |
|
45
|
Parse the raw body as JSON only after verification passes. Act on the `decision` field and return `204`. |
|
46
|
</Step> |
|
47
|
</Steps> |
|
48
|
|
|
49
|
## Code Example |
|
50
|
|
|
51
|
<CodeGroup> |
|
52
|
```typescript Express (TypeScript) |
|
53
|
import express from 'express'; |
|
54
|
import { createHmac } from 'crypto'; |
|
55
|
|
|
56
|
const app = express(); |
|
57
|
|
|
58
|
app.post( |
|
59
|
'/webhooks/procurement', |
|
60
|
express.raw({ type: 'application/json' }), |
|
61
|
(req, res) => { |
|
62
|
const signature = req.headers['x-procure-signature'] as string; |
|
63
|
|
|
64
|
if (!signature) { |
|
65
|
return res.status(401).send('Missing signature'); |
|
66
|
} |
|
67
|
|
|
68
|
const expected = |
|
69
|
'sha256=' + |
|
70
|
createHmac('sha256', process.env.WEBHOOK_SECRET!) |
|
71
|
.update(req.body) |
|
72
|
.digest('hex'); |
|
73
|
|
|
74
|
if (signature !== expected) { |
|
75
|
return res.status(401).send('Invalid signature'); |
|
76
|
} |
|
77
|
|
|
78
|
const payload = JSON.parse(req.body.toString()) as { |
|
79
|
session_id: string; |
|
80
|
decision: 'approved' | 'rejected'; |
|
81
|
actor: string; |
|
82
|
reason?: string; |
|
83
|
}; |
|
84
|
|
|
85
|
if (payload.decision === 'approved') { |
|
86
|
// Resume the suspended orchestration flow for this session |
|
87
|
console.log(`Session ${payload.session_id} approved by ${payload.actor}`); |
|
88
|
} else { |
|
89
|
// Terminate the session |
|
90
|
console.log(`Session ${payload.session_id} rejected: ${payload.reason ?? 'no reason given'}`); |
|
91
|
} |
|
92
|
|
|
93
|
return res.status(204).send(); |
|
94
|
} |
|
95
|
); |
|
96
|
``` |
|
97
|
|
|
98
|
```typescript Next.js API Route |
|
99
|
import { createHmac } from 'crypto'; |
|
100
|
import type { NextApiRequest, NextApiResponse } from 'next'; |
|
101
|
|
|
102
|
export const config = { |
|
103
|
api: { bodyParser: false }, |
|
104
|
}; |
|
105
|
|
|
106
|
async function getRawBody(req: NextApiRequest): Promise<Buffer> { |
|
107
|
return new Promise((resolve, reject) => { |
|
108
|
const chunks: Buffer[] = []; |
|
109
|
req.on('data', chunk => chunks.push(chunk)); |
|
110
|
req.on('end', () => resolve(Buffer.concat(chunks))); |
|
111
|
req.on('error', reject); |
|
112
|
}); |
|
113
|
} |
|
114
|
|
|
115
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) { |
|
116
|
if (req.method !== 'POST') { |
|
117
|
return res.status(405).end(); |
|
118
|
} |
|
119
|
|
|
120
|
const rawBody = await getRawBody(req); |
|
121
|
const signature = req.headers['x-procure-signature'] as string; |
|
122
|
const expected = |
|
123
|
'sha256=' + |
|
124
|
createHmac('sha256', process.env.WEBHOOK_SECRET!) |
|
125
|
.update(rawBody) |
|
126
|
.digest('hex'); |
|
127
|
|
|
128
|
if (signature !== expected) { |
|
129
|
return res.status(401).json({ error: 'Invalid signature' }); |
|
130
|
} |
|
131
|
|
|
132
|
const payload = JSON.parse(rawBody.toString()); |
|
133
|
|
|
134
|
// Handle approved / rejected |
|
135
|
if (payload.decision === 'approved') { |
|
136
|
await resumeSession(payload.session_id); |
|
137
|
} else { |
|
138
|
await terminateSession(payload.session_id, payload.reason); |
|
139
|
} |
|
140
|
|
|
141
|
return res.status(204).end(); |
|
142
|
} |
|
143
|
``` |
|
144
|
</CodeGroup> |
|
145
|
|
|
146
|
## Payload Fields |
|
147
|
|
|
148
|
<ParamField body="session_id" type="string" required> |
|
149
|
The ProcureNet session ID that this approval decision applies to. Use this to look up the suspended session in your orchestration state. |
|
150
|
</ParamField> |
|
151
|
|
|
152
|
<ParamField body="decision" type="string" required> |
|
153
|
The approval outcome. One of: |
|
154
|
- `"approved"` — the session should be resumed and the procurement flow continued. |
|
155
|
- `"rejected"` — the session should be terminated and any reserved resources released. |
|
156
|
</ParamField> |
|
157
|
|
|
158
|
<ParamField body="actor" type="string" required> |
|
159
|
The identity of the person or system that made the approval decision. This value is provided by Procurement Express and may be an email address, username, or system identifier depending on your Procurement Express configuration. |
|
160
|
</ParamField> |
|
161
|
|
|
162
|
<ParamField body="reason" type="string"> |
|
163
|
An optional human-readable explanation for the decision. This is most commonly populated for `rejected` decisions to explain why a session was not approved. Log this value for auditing purposes. |
|
164
|
</ParamField> |
|
165
|
|
|
166
|
### Example Payload — Approved |
|
167
|
|
|
168
|
```json |
|
169
|
{ |
|
170
|
"session_id": "sess_a1b2c3d4", |
|
171
|
"decision": "approved", |
|
172
|
"actor": "procurement-manager@example.com", |
|
173
|
"reason": "Budget confirmed for Q3 vendor onboarding" |
|
174
|
} |
|
175
|
``` |
|
176
|
|
|
177
|
### Example Payload — Rejected |
|
178
|
|
|
179
|
```json |
|
180
|
{ |
|
181
|
"session_id": "sess_e5f6g7h8", |
|
182
|
"decision": "rejected", |
|
183
|
"actor": "compliance-bot", |
|
184
|
"reason": "Vendor not on approved supplier list" |
|
185
|
} |
|
186
|
``` |
|
187
|
|
|
188
|
## Response |
|
189
|
|
|
190
|
Return HTTP `204 No Content` to acknowledge the webhook. Do not return a body. |
|
191
|
|
|
192
|
| Status | Meaning | |
|
193
|
|---|---| |
|
194
|
| `204` | Webhook received and processed successfully | |
|
195
|
| `401` | Signature verification failed — request rejected | |
|
196
|
|
|
197
|
<Note> |
|
198
|
ProcureNet retries webhook delivery on any non-`2xx` response using an exponential back-off policy. Make your handler idempotent — the same `session_id` + `decision` combination may arrive more than once. Check whether the session is already in its target state before applying side effects. |
|
199
|
</Note> |
|
200
|
|
|
201
|
## Decision Handling |
|
202
|
|
|
203
|
<CardGroup cols={2}> |
|
204
|
<Card title="approved" icon="circle-check"> |
|
205
|
Resume the suspended orchestration flow. The session re-enters the state machine at the point it was paused. You may want to call `POST /api/v7/sessions/{id}/transition` to advance to the next state after resuming. |
|
206
|
</Card> |
|
207
|
<Card title="rejected" icon="circle-xmark"> |
|
208
|
Terminate the session immediately. Release any held inventory or funding reservations, notify the buyer, and close the session. Log the `actor` and `reason` fields for your audit trail. |
|
209
|
</Card> |
|
210
|
</CardGroup> |
|
211
|
|
|
212
|
## Security Checklist |
|
213
|
|
|
214
|
<Accordion title="Webhook security best practices"> |
|
215
|
- **Never skip signature verification.** Even in development, always verify the `x-procure-signature` header. |
|
216
|
- **Use a raw body parser.** Applying a JSON parser before verification will invalidate the HMAC check. |
|
217
|
- **Store `WEBHOOK_SECRET` securely.** Treat this secret like a private key — use environment variables or a secrets manager, never hardcode it. |
|
218
|
- **Make handlers idempotent.** ProcureNet may deliver the same event more than once. Guard against double-processing by checking current session state before applying transitions. |
|
219
|
- **Respond promptly.** Complete signature verification and enqueue any heavy work asynchronously. Return `204` within a few seconds to avoid triggering ProcureNet's retry logic. |
|
220
|
- **Log all decisions.** Store the full payload, `actor`, and timestamp for each webhook received. Approval decisions are auditable events. |
|
221
|
</Accordion> |