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