To validate the incoming request you can use the HMAC signature.
Every request will contain these headers:

webhook-signatureThe hmac computed signaturev1,Llc3iSuCVI3/bRv/FoWGuSY3zDL+3ODpDfuzKmLCTpw=
webhook-idThe message IDmsg_28ujvwCbqJ4p0fVuDEgs3MqmreX
webhook-timestamptimestamp when the signature was generated1652073598

The content to sign is composed by concatenating the id, timestamp and payload, separated by the full-stop character (.). In code, it will look something like:

const signed_content = `${webhookId}.${webhookTimestamp}.${body}`;
signed_content = '%s.%s.%s' % (webhookId, webhookTimestamp, body )
$signed_content = sprintf('%s.%s.%s', $webhookId, $webhookTimestamp, $body);

Where the body is the raw body of the request. The signature is sensitive to any changes, so even a small change in the body will cause the signature to be completely different. This means that you should not change the body in any way before verifying.

Magic uses an HMAC with SHA-256 to sign its webhooks.

So to calculate the expected signature, you should HMAC the signedcontent from above using the base64 portion of your signing secret (this is the part after the whsec prefix) as the key. For example, given the secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw you will want to use MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw.

Example of how to generate a signature:

$api_key = 'api_key';
$version = 'V1';
$timestamp = '1234567890123';
$payload = '{"payload":"payload"}';

function createSignature($secret_key, $webhookId, $webhookTimestamp, $rawPayload) :string
    $rawString = sprintf('%s.%s.%s', $webhookId, $webhookTimestamp, $rawPayload);
    return hash_hmac('sha256', $rawString, $api_key);
import hmac
import hashlib

def createSignature(secret_key, webhookId, webhookTimestamp, rawPayload):
    rawString = '%s.%s.%s' % (webhookId, webhookTimestamp, rawPayload )
    return, msg=rawString.encode('utf-8'), digestmod=hashlib.sha256).hexdigest()
const createSignature = (secret_key, webhookId, webhookTimestamp, rawPayload) => {
  const rawBody = `${webhookId}.${webhookTimestamp}.${rawPayload}`;;
  return crypto.createHmac('sha256', secret_key).update(rawBody).digest("hex");

This generated signature should match one of the ones sent in the webhook-signature header.

The webhook-signature header is composed of a list of space-delimited signatures and their corresponding version identifiers. The signature list is most commonly of length one. Though there could be any number of signatures. For example:

v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo=

Make sure to remove the version prefix and delimiter (e.g. v1,) before verifying the signature.

Please note that to compare the signatures it's recommended to use a constant-time string comparison method in order to prevent timing attacks.

Verify timestamp

As mentioned above, Magic also sends the timestamp of the attempt in the webhook-timestamp header. You should compare this timestamp against your system timestamp and make sure it's within your tolerance in order to prevent timestamp attacks.