Webhook request on service order assignment

A service order is automatically created in Extend's system when a claim is approved. A repair service order is then assigned to a servicer. This will trigger a webhook request to the endpoint the servicer has configured. This request will contain relevant service order data, such as customer and product information, and most importantly, the id of the service order. These webhook requests will be signed using asymmetric encryption (RSA-SHA256). The public key from the endpoint below should be used to verify the request. The expected request body is also included below.

Public Key endpoint

/ GET /service-orders/public-key

interface ResponseBody {  
  key: string  
}

The public key will be base64 encoded.

Signature

The webhook request will come with a signature header. The webhook signature will be base64 encoded. The signature will be what is used to verify that the message has not been tampered with or altered. Any message where the signature cannot be verified should be discarded. Use a SHA256 signature verification library to verify the message.

An example of how to verify a digital signature.

import crypto from 'crypto'
/**
 * Returns true if signature verification passed
 *
 * @param body - Stringified body of the webhook
 * @param publicKey - Base64 encoded publicKey. Public key can be fetched at GET /service-orders/public-key
 * The public key endpoint returns the key Base64 encoded.
 * @param signature - Base64 encoded signature. Found in webhook ['signature'] header.
 * The signature found in the request header is Base64 encoded.
 * @returns true if the verification passed
 */
function verifyDigitalSignature(
  body: string,
  publicKey: string,
  signature: string,
): boolean {
  try {
    return crypto.verify(
      'RSA-SHA256',
      Buffer.from(body),
      Buffer.from(publicKey, 'base64'),
      Buffer.from(signature, 'base64'),
    )
  } catch (e) {
    return false
  }
}

// Example usage
const publicKey = 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUF0dkZQY08rbFNVY1c5bi9kdjQ1dwpmWTB5RjQ3Q1haTElDcVM1a1dJeXBmcFAyTlg0V1JESzhjMGJYeDZnOVA0bi9VakpxeURzYStvY01pbDJkV1JECm4yb0dDenJaTHZrNmJ3ZXJRZkNpajBRdnk5MllaQVdKOHIyUXJRTjY5TFp6cnpDcXBNYmxlY2xiNGR1NUw0NnQKSWRXcE91V3g5SldPRC9GL0hST3lkMjYrU0lVdnJzTG5rN3BqU3NSeDFCcDltdytxaklCMnRFRkNJTnJEM0lEYwpGaWNQS01wV0ltaE5BK0J1a3EzWnlTenEyVnZxQXFtaGlmNUVzTHc0WFdYT3VjTTdZV0xuNU1mc2NSaXBwd2t5Cjk0ZW1WRDRIYzNMYUlZZGp4Y1NNZDlHeXI0dmdFUVUrc2dqL2hyQmZqQ2FiTEVzZkVMSWJyQzRhUGFOTjFWVGEKcjkxRXJ5Y0V0bUg1Zk5BaEk3a2ExWVVGdDUrQWd2WERjUHM0dm5IdVRYODZURy8wSU1oU3hyK2p2ZGFyWUpiVApkYkxVMitWaVJTakljM0ZTZzRxanZ4bXlmSk5Ya3dJV0h5NU1oM2tFSzNHQTU3M0dvZnlDVGpvMjBJd2pOd2h5CnMwUGRNbkM5WVhKUUFUUEU0QnZBRGFhQTRVaUtEZU1mcE9YQTRZNmRnQTlIRFJWb0EwUmdOQXljZkhoMGVJeWoKbk9aamFVN25hcEF4UWpyN3hxVHcvOGQ4L01wQ2RBYUFiNzBpTDVwYmJBdFhwdWhCMjdmdktOZ3ZRR0QzK1g2aQpkRWJNWXVKbVpIenFETWZFbUpST2Q0U2pRbDBPUjVMYXk1S3BaeXZqVnNza2gwR0ViVS9IUjg3VVlQOEZKbithCnZEaEc2alUrSHRTREphaEFDWGVhUytzQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==';

const requestBody = '{"serviceOrderId":"73752710-8cdf-4fe3-8327-6936fa3badd6","claimId":"7b67180d-0ae6-4d81-a0ca-d7eeda41870e","contractId":"770e5f9d-fb2d-4c53-9137-cbb647d037f5","servicerId":"26db19a3-d9db-4ad4-9575-b7ca2f6e990a","customerName":"Paul Name","customerEmail":"[email protected]","contractPurchaseDate":1655668093818,"customerShippingAddress":{"city":"San Francisco","address1":"533 Mission Street","provinceCode":"CA","countryCode":"USA","postalCode":"94105"},"customerPhoneNumber":"555-535-5555","productDetails":[{"productReferenceId":"PRODUCT-REPAIR-385UmuLNaUqVmpohrmnvMqrncttQCz","productListPrice":25199,"productPurchasePrice":25199}],"failureType":"webhook parse","failureDescription":"webhook","incident":{"failureType":"electricalFailure","description":"BROKE PLZ HALP","occurredAt":1695152893819,"FailureType":"webhook parse","FailureDescription":"webhook","productCondition":"nonfunctional"},"sendDate":1695152952396,"url":"https://webhook.site/70a09585-f934-4b01-855b-0869e5e19752","transactionId":"63815176-f685-44ca-8180-fb13e39d5d04","type":"assign"}';
const signature = 'JJbRyjmmylP4GJtNCT7wANYVMkmUYENuX13sXqMjbAUcfidBBix/hzCjnoycg6lCT1Ktny2OnmixEHQAwNFOuReTlid+AFvCQnQ0nA+aUjvD3JSYCLLE2SOuvbZ7/Q/fQZNnFrDUX6MXcYkuoNLUmKRagG5uq0UyIEtWgfyLtGJQlZ91KXJkvZsFQu+AO5dFiDceJA+IW11sCN5e/9gKWuHxzxwKepvA8S0lyPwQWYqk1V/O/bV5s5uD5zrI6Wndd8Yq7ejYsWd6ZeHbjrcTbbr35lKsTcpoLxtBjD+H/1X5+pYAPlW7ymLWpPxEAug8tnpzy5HVMtwNZ3br8S5afC9BgR7abGcfa3PjyYvM1tk6+JfCPLa9gIJShu1+eXTq0GUQnxu/0bL/ihnwadHlFZyLVzmRjBkPrsuQsM6KTvXS5Gw+BGo+zy/p7iFUFHws1r6zZNONe0ZxTudpDgGN7IoXmvyj7OOJ7VyGROggB0Ptu2iiNz81uTT1M9z+QCR2pa9E+a6fTWTZIXP86acb8HuKDb8Rd9wkU4G609aZc3QHX0ANKIFSWqhz9WDh0XZgT8daMuda+TmtI4NkTS7j9yQLqvbYQ1W2RpCKVBLgXCgiylBoHfRWFZ/dlOdkojBWaFnWa8ytU69IHFgVAd4V4RVPgjNyWgwovtQtiAvpT3o=';

if (verifyDigitalSignature($signature, $publicKey, $requestBody)) {
    console.log("Signature is valid!")
} else {
    console.log("Signature is not valid.")
}

<?php
// Signature is base64 encoded and found in the 'signature' header on the request
// Public Key returned from Extend API as base64 encoded string
// Request body. Stringified JSON body sent in the webhook request
function verifyDigitalSignature($signature, $publicKey, $requestBody)
{
    // Load the public key
    $publicKeyResource = openssl_pkey_get_public(base64_decode($publicKey));

    if ($publicKeyResource === false) {
        return false; // Failed to load the public key
    }

    // Verify the signature
    $result = openssl_verify($requestBody, base64_decode($signature), $publicKeyResource, OPENSSL_ALGO_SHA256);


    return ($result === 1); // 1 means the signature is valid
}

// Example usage
$publicKey = 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUF0dkZQY08rbFNVY1c5bi9kdjQ1dwpmWTB5RjQ3Q1haTElDcVM1a1dJeXBmcFAyTlg0V1JESzhjMGJYeDZnOVA0bi9VakpxeURzYStvY01pbDJkV1JECm4yb0dDenJaTHZrNmJ3ZXJRZkNpajBRdnk5MllaQVdKOHIyUXJRTjY5TFp6cnpDcXBNYmxlY2xiNGR1NUw0NnQKSWRXcE91V3g5SldPRC9GL0hST3lkMjYrU0lVdnJzTG5rN3BqU3NSeDFCcDltdytxaklCMnRFRkNJTnJEM0lEYwpGaWNQS01wV0ltaE5BK0J1a3EzWnlTenEyVnZxQXFtaGlmNUVzTHc0WFdYT3VjTTdZV0xuNU1mc2NSaXBwd2t5Cjk0ZW1WRDRIYzNMYUlZZGp4Y1NNZDlHeXI0dmdFUVUrc2dqL2hyQmZqQ2FiTEVzZkVMSWJyQzRhUGFOTjFWVGEKcjkxRXJ5Y0V0bUg1Zk5BaEk3a2ExWVVGdDUrQWd2WERjUHM0dm5IdVRYODZURy8wSU1oU3hyK2p2ZGFyWUpiVApkYkxVMitWaVJTakljM0ZTZzRxanZ4bXlmSk5Ya3dJV0h5NU1oM2tFSzNHQTU3M0dvZnlDVGpvMjBJd2pOd2h5CnMwUGRNbkM5WVhKUUFUUEU0QnZBRGFhQTRVaUtEZU1mcE9YQTRZNmRnQTlIRFJWb0EwUmdOQXljZkhoMGVJeWoKbk9aamFVN25hcEF4UWpyN3hxVHcvOGQ4L01wQ2RBYUFiNzBpTDVwYmJBdFhwdWhCMjdmdktOZ3ZRR0QzK1g2aQpkRWJNWXVKbVpIenFETWZFbUpST2Q0U2pRbDBPUjVMYXk1S3BaeXZqVnNza2gwR0ViVS9IUjg3VVlQOEZKbithCnZEaEc2alUrSHRTREphaEFDWGVhUytzQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==';

$requestBody = '{"serviceOrderId":"73752710-8cdf-4fe3-8327-6936fa3badd6","claimId":"7b67180d-0ae6-4d81-a0ca-d7eeda41870e","contractId":"770e5f9d-fb2d-4c53-9137-cbb647d037f5","servicerId":"26db19a3-d9db-4ad4-9575-b7ca2f6e990a","customerName":"Paul Name","customerEmail":"[email protected]","contractPurchaseDate":1655668093818,"customerShippingAddress":{"city":"San Francisco","address1":"533 Mission Street","provinceCode":"CA","countryCode":"USA","postalCode":"94105"},"customerPhoneNumber":"555-535-5555","productDetails":[{"productReferenceId":"PRODUCT-REPAIR-385UmuLNaUqVmpohrmnvMqrncttQCz","productListPrice":25199,"productPurchasePrice":25199}],"failureType":"webhook parse","failureDescription":"webhook","incident":{"failureType":"electricalFailure","description":"BROKE PLZ HALP","occurredAt":1695152893819,"FailureType":"webhook parse","FailureDescription":"webhook","productCondition":"nonfunctional"},"sendDate":1695152952396,"url":"https://webhook.site/70a09585-f934-4b01-855b-0869e5e19752","transactionId":"63815176-f685-44ca-8180-fb13e39d5d04","type":"assign"}';
$signature = 'JJbRyjmmylP4GJtNCT7wANYVMkmUYENuX13sXqMjbAUcfidBBix/hzCjnoycg6lCT1Ktny2OnmixEHQAwNFOuReTlid+AFvCQnQ0nA+aUjvD3JSYCLLE2SOuvbZ7/Q/fQZNnFrDUX6MXcYkuoNLUmKRagG5uq0UyIEtWgfyLtGJQlZ91KXJkvZsFQu+AO5dFiDceJA+IW11sCN5e/9gKWuHxzxwKepvA8S0lyPwQWYqk1V/O/bV5s5uD5zrI6Wndd8Yq7ejYsWd6ZeHbjrcTbbr35lKsTcpoLxtBjD+H/1X5+pYAPlW7ymLWpPxEAug8tnpzy5HVMtwNZ3br8S5afC9BgR7abGcfa3PjyYvM1tk6+JfCPLa9gIJShu1+eXTq0GUQnxu/0bL/ihnwadHlFZyLVzmRjBkPrsuQsM6KTvXS5Gw+BGo+zy/p7iFUFHws1r6zZNONe0ZxTudpDgGN7IoXmvyj7OOJ7VyGROggB0Ptu2iiNz81uTT1M9z+QCR2pa9E+a6fTWTZIXP86acb8HuKDb8Rd9wkU4G609aZc3QHX0ANKIFSWqhz9WDh0XZgT8daMuda+TmtI4NkTS7j9yQLqvbYQ1W2RpCKVBLgXCgiylBoHfRWFZ/dlOdkojBWaFnWa8ytU69IHFgVAd4V4RVPgjNyWgwovtQtiAvpT3o=';

if (verifyDigitalSignature($signature, $publicKey, $requestBody)) {
    echo "Signature is valid!";
} else {
    echo "Signature is not valid.";
}
?>

Full featured example of handling webhook built with AWS lambda.

import crypto from 'crypto'
import fetch from 'node-fetch'

const lambda = async event => {
  const { headers, body } = event
  // Signature is a base64 encoded string in header 'siganture'
  const signature = headers['signature']

  console.log('webhook received')
  if (!signature) {
    console.log('Webhook does not contain signature. Unauthorized')
  	return { statusCode: 401 }
  }
  // Public Key returned as base64 encoded string
  const publicKey = await getPublicKey()
  console.log('attempting to verify RSA-SHA256 signature')
  
  const verification = verifyDigitalSignature(body, publicKey, signature)
  if (!verification) {
    console.log('Unable to verify signature. Unauthorized')
  return { statusCode: 401 }
  }
  
  console.log('signature verification passed')
  return { statusCode: 200 }
}

/**
 * Returns true if signature verification passed
 *
 * @remarks
 * The crypto package is a built-in Node module
 *
 * @param body - Stringified body of the webhook
 * @param publicKey - Base64 encoded publicKey. Public key can be fetched at GET /service-orders/public-key
 * @param signature - Base64 encoded signature. Found in webhook ['signature'] header.
 * @returns true if the verification passed
 */
function verifyDigitalSignature(
  body: string,
  publicKey: string,
  signature: string,
): boolean {
  try {
    return crypto.verify(
      'RSA-SHA256',
      Buffer.from(body),
      Buffer.from(publicKey, 'base64'),
      Buffer.from(signature, 'base64'),
    )
  } catch (e) {
    console.log(e)
    return false
  }
}

async function getPublicKey(): Promise<string> {
  console.log('Getting Extend Public Key')
  const API_HOST = 'https://api.helloextend.com/'
  const url = `${API_HOST}/service-orders/public-key`
  const publicKeyResponse = await fetch(url, {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
    },
  })
  const key = (await publicKeyResponse.json()).key
  if (!key) {
    throw new Error('Unable to parse public key')
  }
  return key
}


Webhook Request Body

interface AssignedWebhook {
  contractId: string
  contractPurchaseDate: number
  productDeliveryDate?: number
  claimId: string
  serviceOrderId: string
  serviceAmountAuthorized?: number
  orderNumber?: string
  customerName: string
  customerEmail: string
  customerShippingAddress: Address
  customerPhoneNumber?: string
  productDetails: ProductDetails[]
  failureType: string
  failureDescription?: string
  incident: {
    FailureType: string
    // Can include additional properties
  }
  sendDate: number
  servicerId: string
  url: string
  transactionId: string
  type: string
}

interface ProductDetails {
  productReferenceId: string
  sku?: string
  productListPrice?: number // In cents
  productPurchasePrice?: number // In cents
  productVertical?: string
}

interface Address {
  address1: string
  address2?: string
  city: string
  countryCode: string
  postalCode: string
  provinceCode?: string
}

Example

{
  "serviceOrderId": "4319c6c7-48aa-4086-a114-b63d9161aa18",
  "claimId": "09c6dfc3-3e29-4b3d-a83a-19d9b2c5971a",
  "contractId": "15e0abb5-6b96-4f68-827d-ff962027221f",
  "servicerId": "3855a17d-3b61-44c9-b45c-43469e9f1d0a",
  "customerName": "Customer Name",
  "customerEmail": "[email protected]",
  "contractPurchaseDate": 1654117627000,
  "customerShippingAddress": {
    "address2": "Ste 1",
    "city": "San Francisco",
    "address1": "1 Main St",
    "provinceCode": "CA",
    "countryCode": "US",
    "postalCode": "11111"
  },
  "customerPhoneNumber": "111-111-1111",
  "productDetails": [
    {
      "productReferenceId": "referenceId",
      "productListPrice": 25199,
      "productPurchasePrice": 25199
    }
  ],
  "failureType": "electricalFailure",
  "failureDescription": "The description of the failure.",
  "incident" : {
    "FailureType": "electricalFailure",
  },
  "sendDate": 1690910629602,
  "url": "https://servicer-webhook-url/post-webhook",
  "transactionId": "b16a3b34-636c-4941-b69b-8eaf57b62c9f",
  "type": "assign"
}

Webhooks should be responded to with a status code 200. All requests that do not receive a 200 will be retried every hour for 48 hours.