Skip to content
Last updated

Webhook Validation

ShipStation includes a digital signature (RSA-SHA256) in all outgoing webhooks. This allows you to ensure requests received at your webhook URL were sent from our systems.

We have a full code example below that demonstrates all the steps.

Validation Process

Step 1: Extract the Signature Headers

Extract the three signature headers from the incoming webhook request:

  • x-shipengine-rsa-sha256-key-id
  • x-shipengine-rsa-sha256-signature
  • x-shipengine-timestamp

If these headers are not present, you should respond with an HTTP status 404 and stop processing the request. This can help hide the existence of your webhook endpoint from anyone attempting to impersonate our service.

Step 2: Validate the Timestamp

Verify that the timestamp in the x-shipengine-timestamp header is recent, in order to prevent replay attacks. Use your judgement on the age of webhooks you are willing to accept. Note that because of different server time skews, you may receive webhooks with timestamps in the future, so your code should account for that. If the timestamp header is more than 5 minutes difference from the current time, you may want to respond with an HTTP status 400 and stop processing the request. If you encounter a lot of these rejections, you may want to double-check your server clocks, or increase the time range.

Step 3: Get the Raw Request Body

Important

You must use the raw, unparsed request body exactly as received. Do not parse the JSON first and then re-serialize it, as this may change whitespace, property ordering, or encoding, which will cause signature verification to fail. Ensure your web server framework provides access to the unparsed body.

Step 4: Retrieve the Public Key

Fetch the JSON Web Key Set (JWKS) from our public endpoint: https://api.shipengine.com/jwks

The JWKS endpoint returns a standard RFC 7517 JSON Web Key Set containing our public keys. Find the key in the JWKS whose kid (key ID) matches the x-shipengine-rsa-sha256-key-id header value.

The set of keys does not change very often, so it is generally safe to cache the JWKS response for a long period of time. If you receive a webhook request with a x-shipengine-rsa-sha256-key-id value that is not in your cached copy, you should fetch the latest JWKS. The response includes an ETag header, which you can pass in subsequent requests via the If-None-Match header. If the contents hasn't changed, our JWKS endpoint will respond with status 304. If it responds with a status 200, it means the JWKS has changed, has a new ETag, and you should update your cache.

ShipStation may periodically rotate our signing keys. As long as you follow these guidelines related to fetching the JWKS, you should not have any service interruption. A public key will always be present in the JWKS before we start using it for signing outgoing requests.

Step 5: Verify the Signature

To verify the signature, you must first construct the signed payload. This is the value that was hashed using our private key to produce the signature. The signed payload is constructed by concatenating the value from the timestamp header, a literal period (.), followed by the raw request body.

Example:

2025-10-02T04:51:00Z.{"resource_url":"https://api.shipstation.com/example","resource_type":"EXAMPLE"}

Use an RSA SHA-256 validation function on this signed payload, along with the public key from the previous step, to verify the signature.

If the signature validation fails, you should respond with an HTTP status 401, and discard the payload without any further processing.

Complete Example

We've included a full working example of a NodeJS server that receives and validates webhooks, so that you can use it as a reference in your own implementation.

const crypto = require('crypto');

// Cache for JWKS (in production, use a proper caching mechanism)
let jwksCache = null;
let jwksCacheETag = null;

class MissingHeadersError extends Error {}
class TimestampError extends Error {}
class SignatureError extends Error {}

/**
 * Validates webhook signature
 * @throws {MissingHeadersError} When required headers are missing (should return 404)
 * @throws {TimestampError} When timestamp is out of range (should return 400)
 * @throws {SignatureError} When signature validation fails (should return 401)
 */
async function validateWebhookSignature(headers, rawBody) {
  const keyId = headers['x-shipengine-rsa-sha256-key-id'];
  const signature = headers['x-shipengine-rsa-sha256-signature'];
  const timestamp = headers['x-shipengine-timestamp'];

  if (!keyId || !signature || !timestamp) {
    throw new MissingHeadersError('Missing required signature headers');
  }

  // Validate timestamp (5 minute window)
  const webhookTime = new Date(timestamp);
  const now = new Date();
  const ageMinutes = (now - webhookTime) / 1000 / 60;

  if (Math.abs(ageMinutes) > 5) {
    throw new TimestampError(`Webhook timestamp too old or too far in future: ${ageMinutes} minutes`);
  }

  // Get public key
  const publicKey = await getPublicKey(keyId);
  if (!publicKey) {
    throw new SignatureError(`Public key not found for kid: ${keyId}`);
  }

  // Construct signed payload
  const signedPayload = `${timestamp}.${rawBody}`;

  // Verify signature
  const verify = crypto.createVerify('RSA-SHA256');
  verify.update(signedPayload, 'utf8');
  verify.end();

  const isValid = verify.verify(
    publicKey,
    signature,
    'base64'
  );

  if (!isValid) {
    throw new SignatureError('Invalid webhook signature');
  }

  return true;
}


/**
 * Gets public key for a given key ID
 * Handles caching and automatic refresh if key not found
 * @returns Public key object or null if not found
 */
async function getPublicKey(keyId) {
  // Try to find in cached JWKS
  if (jwksCache) {
    const jwk = jwksCache.keys.find(k => k.kid === keyId);
    if (jwk) {
      return jwkToPem(jwk);
    }
  }

  // Key not found in cache, fetch fresh JWKS
  const jwks = await fetchJWKS();
  const jwk = jwks.keys.find(k => k.kid === keyId);

  if (!jwk) {
    return null; // Key not found
  }

  return jwkToPem(jwk);
}

/**
 * Fetches the JWKS from ShipEngine
 */
async function fetchJWKS() {
  const headers = {};
  if (jwksCacheETag) {
    headers['If-None-Match'] = jwksCacheETag;
  }

  const response = await fetch('https://api.shipengine.com/jwks', {
    method: 'GET',
    headers
  });

  if (response.status === 304 && jwksCache) {
    // Not modified, use cache
    return jwksCache;
  }

  if (!response.ok) {
    throw new Error(`Failed to fetch JWKS: ${response.status}`);
  }

  jwksCache = await response.json();
  jwksCacheETag = response.headers.get('etag');

  return jwksCache;
}

/**
 * Converts JWK to PEM format public key
 */
function jwkToPem(jwk) {
  const modulus = Buffer.from(jwk.n, 'base64');
  const exponent = Buffer.from(jwk.e, 'base64');

  // Create public key from modulus and exponent
  const key = crypto.createPublicKey({
    key: {
      kty: 'RSA',
      n: jwk.n,
      e: jwk.e
    },
    format: 'jwk'
  });

  return key;
}

// Express.js middleware example
function webhookValidationMiddleware(req, res, next) {
  // Capture raw body
  let rawBody = '';

  req.on('data', (chunk) => {
    rawBody += chunk.toString('utf8');
  });

  req.on('end', async () => {
    try {
      await validateWebhookSignature(req.headers, rawBody);
      req.body = JSON.parse(rawBody); // Now safe to parse
      next();
    } catch (error) {
      console.error('Webhook validation failed:', error.message);

      if (error instanceof MissingHeadersError) {
        res.status(404).send();
      } else if (error instanceof TimestampError) {
        res.status(400).json({ error: error.message });
      } else if (error instanceof SignatureError) {
        res.status(401).json({ error: 'Invalid webhook signature' });
      } else {
        res.status(500).json({ error: 'Internal server error' });
      }
    }
  });
}

const express = require('express');
const app = express();
app.post('/webhook', webhookValidationMiddleware, (req, res) => {
  // Process validated webhook
  console.log('Validated webhook:', req.body);
  res.status(200).send('OK');
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook server listening on port ${PORT}`);
});


// How to run:
// 1. Save this entire code block into a file named server.js
// 2. npm install --save express
// 3. npm start