|
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> |