Implementing Webhooks
To make the best use of webhook events, there are some general requirements and best practices that we recommend. This article reviews what external developers need to know to properly implement webhooks.
Recommendations
First and foremost, it is important to ensure that the HTTP endpoint (destination URL) is working properly before configuring webhooks. Extensiv's webhooks system attempts to resend data in the case of failure, ensuring that the endpoint is working properly beforehand will save costs by reducing the data usage expended on message retries for failing endpoints.
We highly recommend the endpoint validates the validity of the message via its RSA signature before accepting and processing any data. (Read RSA signature and validation below.)
General process
Extensiv's webhooks are designed to wait for an endpoint's response for a maximum of three seconds before flagging the request as a delivery failure and forwarding the request to the retry mechanism. Because of this, endpoints should operate in the following order:
- Perform RSA validation to validate the message source
- Reply with an HTTP 20x success message
- Parse the message and process the message content
If your endpoint responds with an error (or if the webhook system fails to receive a success response from your endpoint), Extensiv resends the webhook notification at regular intervals for roughly six hours. If the endpoint does not respond within that time, the message is pushed to a "dead letter queue" where it will be available for an additional 3 days before being deleted. If you need to access a message in the dead letter queue, please contact api@extensiv.com.
Payload structure
A webhook message contains the payload of an HTTP POST to the target URI with Content-Type: application/json. The message is used as the body of the request that the webhook Lambda forwards to the provided URL.
So that you can understand how to properly parse and access the data, below is an example of the message and resource structure of a webhook payload:
{
"headers": {
"Signature": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"body": {
"tplId": 2,
"wmsEventId": 2070354,
"dateTime": "2022-01-07T19:54:15.4770000",
"eventType": "OrderConfirm",
"resource": {
"rel": "orders/order",
"href": "/orders/206568?detail=OrderItems",
"body": "{//<body content>}"
},
"links": "{\"uiproperties/user\": {\"LastModifiedBy\": \"/uiproperties/users/-1\"},\"customers/customer\":\"/customers/143\",\"properties/facility\":\"/properties/facilities/10\"}",
"data": "{\"OrderId\":\"206568\"}",
"tags": "Shipped"
}
}
- tplId:Â Unique 32-bit integer value assigned in the TPL master database when a TPL is created
- wmsEventId: Primary key to the WMSEvent table—64-bit integer value unique within the TPL
-
dateTime:Â When the event occurred in UTC
- Note: The webhooks system may deliver requests in a non-chronologic order—therefore, endpoints should not rely on delivery order of messages to be chronological. Instead, you should utilize the dateTime value included in the payload for this purpose.
- eventType:Â Member of the EventType enumeration in string form
-
resource:Â Specifies the resource that was the object of the eventType
- rel:Â Reference to this kind of resource in the Rel documentation
- href:Â Relative URL to GET the resource
- body: Present only if IncludeResource=true (in the webhook configuration)—if present, its format is as described in the referenced Rel documentation
- links:Â String in escaped-json form, containing selected Rel/Href pairs relevant to the resource
- data:Â String in escaped-json form, containing selected fields relevant to the event
- tags:Â If present, a comma-delimited list of tags describing the state of the resource as of the event
The JSON payload always comes through in UTF-8 for counting purposes. Extensiv counts the message size based on the content read in UTF-8, not including the RSA signature.
RSA signature and validation
To ensure that webhook messages have not been tampered with, a header named "Signature" is included in the http POST request. The RSA signature is created by the webhook Lambda, using SHA256 encryption against an internal RSA private key and passphrase to sign the message body into a base 64 string:
"headers":{
"Signature":"AG17G6BQpQOuzmYi6YXXvXip11DkU8/QQvaF+piYGbLJjtIP6883aU3klOGgGIF1tuSLACNwiXLmalk7z4W9gbtcgg=="
}
On the client-side, the following validation process can be used to ensure that the message body has not been tampered with:
Step 1: Retrieve the RSA public key through an HTTP GET request using endpoint https://secure-wms.com/events/webhook/key.
- A "publicKey" attribute is returned, which contains the RSA public key's value.
- A second attribute "retrievalDateISO" can be recorded client-side to track the date/time that the public key was last retrieved.
- "previousRetrievalDateISO" may be included as an optional parameter in the body of the POST request. When included, a 304 status code and an empty request body will be returned in the case that the public key has not been updated since the previous retrieval date/time.
Step 2:Â Using a cryptography library that supports RSA validation, perform RSA validation on the request's message body using SHA256 encryption and base64 string format, validating with the RSA signature and public key. Ensure that validation on the message body using the header.Signature and retrieved RSA public key yields a successful validation. Here is an example of implementation in typescript:
- RSA signature format: SHA256 encryption, base64 string
- Public key format: pem
- Public key type: spki
- RSA signature: headers.Signature
- RSA public key: Retrieved in Step 1 above
If validation fails, it may be due to using an outdated RSA public key. Options to avoid this situation include the following:
- Ensure that the validation process always checks for an updated public key (Step 1 above) before attempting validation, or
- Implement a fail-safe mechanism to retrieve the public key (from Step 1 above) and attempt validation a second time upon failing once.
/**
* @description Validates the RSA signed signature passed in with the request event using public key
* @param {requestEvent} request - The request event which should contain a body and a headers.Signature
* @returns true if validation is successful, false if validation fails
*/
function validateMessage(request: requestEvent): boolean {
//Check that the request has a body and a headers.Signature
if(request.body && request.headers && request.headers.Signature) {
//Create the publicKey
const publicKey: KeyObject = createPublicKey({
'key': <RSA_Public_Key_Retrieved_From_Endpoint_In_Step_1>,
'format': 'pem',
'type': 'spki',
});
//Returns true if validation is successful, false if unsuccessful
return createVerify('SHA256').update(request.body)
.verify(publicKey, request.headers.Signature, 'base64')
}
return false;
}