Guide: Webhooks
This guide explains how to hook synchronous events or receive asynchronous ones using webhooks.
What Are Webhooks?
A webhook is an API endpoint (usually HTTP) that can be called by a third party in response to some particular trigger. Webhooks are therefore a way to be notified when something occurs, so your application can act on it accordingly.
Webhooks With SlashID
You can use the SlashID API to register webhooks for your organization. You can use webhooks to be notified when triggers occur within SlashID - for example, when a user fails to authenticate on your frontend. The webhook request will contain information about the trigger, as well as information about the webhook that you can use to verify the incoming request.
Creating a Webhook
You can use the SlashID webhooks API to create and manage webhooks. Each webhook has a target URL, which is the
endpoint that will be called in response to a trigger. This URL must be an HTTPS URL, and must have a non-empty host
that is neither localhost
nor an IP address.
It may not contain any query parameters or fragments. It must be unique amongst all of your organization's webhooks.
You can also specify custom headers per webhook that will be sent with each request. These can be used to include additional information in the request, such as authorization. These are encrypted at rest.
When creating a webhook, you may specify a timeout. This is the maximum amount of time that the SlashID client will wait for a response from your application after sending a webhook request. If your application fails to respond within the timeout, the call will be regarded as having failed. If you do not set a timeout when creating a webhook, default timeouts will be used. For event triggers, the default is 10 seconds. For synchronous hook triggers, the default is 2 seconds.
When you create a webhook, it has no associated triggers, and so will never be called. You can add triggers using the create trigger endpoint. A trigger is specified by a trigger type and trigger name. The trigger name indicates which specific trigger of the given type should cause the webhook to be called. For example, the trigger
{
"trigger_type": "event",
"trigger_name": "AuthenticationSucceeded_v1"
}
indicates that the webhook should fire on the event AuthenticationSucceeded_v1
. Each webhook can have multiple
triggers, and multiple webhooks can have the same trigger. Creating the same trigger twice for a single webhook has
no effect.
Note that some triggers may fire frequently, and so you should consider which triggers you associate with webhooks, and whether rate limiting or other controls are required.
If you have multiple webhooks for a single trigger, they will be called in parallel, and there is no guarantee on the order in which they are called.
SlashID supports the following trigger types:
event
sync_hook
For event triggers, the following trigger names are supported:
AuthenticationSucceeded_v1
AuthenticationFailed_v1
PersonCreated_v1
AnonymousPersonCreated_v1
PersonDeleted_v1
VirtualPageLoaded_v1
SlashIDSDKLoaded_v1
PersonIdentified_v1
PersonLoggedOut_v1
TokenMinted_v1
AnonymousTokenMinted_v1
PasswordChanged_v1
GdprConsentsChanged_v1
GateServerStarted_v1
GateRequestHandled_v1
GateRequestCredentialFound_v1
PermissionCreated_InRegion_v1
PermissionCreated_v1
PermissionDeleted_InRegion_v1
PermissionDeleted_v1
RoleCreated_InRegion_v1
RoleCreated_v1
RoleDeleted_InRegion_v1
RoleDeleted_v1
RoleUpdated_InRegion_v1
RoleUpdated_v1
RolesSetToPerson_InRegion_v1
RolesSetToPerson_v1
PermissionsSetToPerson_InRegion_v1
PermissionsSetToPerson_v1
PermissionUpdated_InRegion_v1
PermissionUpdated_v1
MitmAttackDetectedV1
For synchronous hook triggers, the following trigger names are supported:
token_minted
For more information on events in SlashID, see the events documentation. For more information on synchronous hooks in SlashID, see the synchronous hooks documentation.
Handling a Webhook Request
Once you have created a webhook and added triggers for it, you will start to receive requests to the webhook.
Requests will be HTTP POST
requests with content type application/jwt
, to the target URL specified. It will also contain
the custom headers you set when creating the webhook. Your application must return a 2XX
status code in the response
for the webhook call to be treated as successful. Note that 1XX
information codes and 3XX
redirect codes are not accepted, and are not regarded
as successful.
For event triggers, if your application returns a non-successful status code (greater than 299
) or times out, the call will be retried
according to an exponential backoff strategy. The initial backoff will be 1 minute, and the maximum backoff is 10
minutes. The call will be retried for up to 30 minutes before being dropped.
For synchronous hook triggers, if your application returns a non-successful status code
(greater than 299
) or times out, the call will be retried two more times at intervals
of 100ms, for a total of 3 attempts at most. If the final call fails or times out, the webhook call
will be regarded as having failed. This may affect the flow that is being hooked - for example,
the token_minted
synchronous hook occurs during authentication, and failure to call relevant
webhooks will result in the user authentication failing.
Note that for event triggers, it is possible for the webhook to be called more than once for a single event. As such,
it is recommended that your webhook handling be idempotent and/or include some deduplication mechanism. The sub
field in the webhook payload can be used to deduplicate webhook calls if needed.
When handling a request to a webhook, it is essential that you verify the contents of the request before processing it further. With SlashID, we have made this simple by using the JSON Web Token (JWT) and JSON Web Key (JWK) standards. The body of the request to the webhook is a signed and encoded JWT (just like our authentication tokens). In order to verify it, you should first retrieve the verification key JSON Web Key Set (JWKS) for your organization using the API. (Note that this endpoint is rate-limited, so we recommend caching the verification key.) You can then use this key to verify the JWT signature, and decode the body.
The JWT body will contain the following claims that you can use to validate the contents of the request:
jwt_id
: a UUID uniquely identifying this invocation of the webhook for this triggeraud
: your organization IDsub
: a unique identifier for the instance of the trigger (for example, the event ID)issuer
: the URL of the SlashID API that issued this tokenissued_at
: the time when the webhook request was madeexpires_at
: the expiry time of the request, after which the request should be rejected; set to 5 minutes afterissued_at
webhook_id
: the ID of the webhook that was triggeredtarget_url
: the target URL of the webhook that was triggeredtrigger_type
: the type of trigger that caused this requesttrigger_name
: which trigger caused this requesttrigger_content
: the full content of the trigger
You should check at least the following:
- the JWT signature is valid, using the verification key
- the expiry time has not elapsed
- the organization ID is your SlashID organization ID
- the issuer is the same as the base path from which you retrieved the verification key
- the target URL matches the URL handling the request
You may also check that the webhook ID and trigger are as expected.
The body does not contain the custom headers; it is expected that your application will be able to verify these independently, if needed.
The trigger content contains the full details of the trigger, which depends on the trigger type and name. For event triggers, the trigger content will be the full event payload - please refer to our events documentation for details. For synchronous hook triggers, the trigger content depends on the type of hook - please refer to our hooks documentation.
For event triggers, any response body from your webhook server will be ignored. For synchronous hook triggers, you may optionally return a response body that can affect the hooked flows - please refer to our hooks documentation for details.
Validating a SlashID webhook request
Below are example implementations of secure webhook request validation.
- Python
- Go
- TypeScript
from fastapi import HTTPException, status
import jwt
def verify_webhook(token):
"""
Verifies JWT token from request data using JWKS from provided endpoint.
:param token: Incoming request object.
:return: Decoded token if valid.
:raises: HTTPException with status 401 if token is invalid.
"""
# Initialize JWKS client
jwks_client = jwt.PyJWKClient(
"https://api.slashid.com/organizations/webhooks/verification-jwks",
headers={"SlashID-OrgID": "<ORGANIZATION ID>"}
)
webhookURL = "https://my-service.com/sid/webhook"
try:
# Get unverified header from JWT
header = jwt.get_unverified_header(token)
# Fetch the signing key from JWKS using the 'kid' claim from the JWT header
key = jwks_client.get_signing_key(header["kid"]).key
# Decode the JWT using the fetched signing key
verified_token = jwt.decode(token, key, audience="<ORGANIZATION ID>", algorithms=["ES256"])
if verified_token['target_url'] != webhookURL:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token {token} is invalid: {e}",
)
# Return decoded token
return verified_token
except Exception as e:
# Raise exception if JWT is invalid
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token {token} is invalid: {e}",
)
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"github.com/google/tink/go/jwt"
)
const jwksURL = "https://api.slashid.com/organizations/webhooks/verification-jwks"
const orgID = "<ORGANIZATION ID>"
const sidBaseURL = "https://api.slashid.com"
const (
svcBaseURL = "https://my-service.com"
sidWebhookPath = "/sid/webhook"
)
func getJWKS() ([]byte, error) {
req, err := http.NewRequest("GET", jwksURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("SlashID-OrgID", orgID)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
jwks, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return jwks, nil
}
func verifyWebhook(reqBody string) (*jwt.VerifiedJWT, error) {
// Retrieve the verification key for your organization using the SlashID APIs
verificationJWKS, err := getJWKS()
if err != nil || verificationJWKS == nil {
return nil, err
}
verificationKeyset, _ := jwt.JWKSetToPublicKeysetHandle(verificationJWKS)
// Build the verifier and validator
verifier, _ := jwt.NewVerifier(verificationKeyset)
issuer := sidBaseURL
aud := orgID
validator, _ := jwt.NewValidator(&jwt.ValidatorOpts{
ExpectedIssuer: &issuer,
AllowMissingExpiration: false,
ExpectedAudience: &aud,
})
// Verify the JWT - this will include the issuer, audience, and expiration
verifiedJWT, err := verifier.VerifyAndDecode(reqBody, validator)
if err != nil {
// The request body cannot be verified as coming from SlashID and so should not be trusted!
return nil, fmt.Errorf("webhook request body %s is invalid: %v", reqBody, err)
}
// Check the target URL matches the endpoint this handler is associated with
targetURL, err := verifiedJWT.StringClaim("target_url")
if err != nil {
return nil, fmt.Errorf("webhook request body is missing the `target_url` claim: %w", err)
}
expectedTargetURL, err := url.JoinPath(svcBaseURL, sidWebhookPath)
if err != nil {
return nil, err
}
if targetURL != expectedTargetURL {
// This URL does not seem to be the intended target
return nil, fmt.Errorf("token %s is invalid, wrong URL: %v", reqBody, err)
}
return verifiedJWT, nil
}
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const JWKS_URL = "https://api.slashid.com/organizations/webhooks/verification-jwks";
const ORG_ID = "<ORGANIZATION ID>";
const WEBHOOK_URL = "https://my-service.com/sid/webhook";
async function verifyJWT(token: string): Promise<any> {
if (token == undefined || token == "" ) {
throw new Error("Token is missing");
}
const client = jwksClient({
jwksUri: JWKS_URL,
requestHeaders: { "SlashID-OrgID": ORG_ID }, // Set request headers
});
const header = jwt.decode(token, { complete: true })?.header;
return new Promise((resolve, reject) => {
client.getSigningKey(header?.kid, (err: Error, key: any) => {
if (err) {
return reject(`Token ${token} is invalid: ${err.message}`);
}
if (key == undefined) {
return reject(`Token ${token} is invalid: no key found`);
}
const signingKey = key.getPublicKey();
try {
const verifiedToken = jwt.verify(token, signingKey, {
audience: ORG_ID,
algorithms: ["ES256"]
}) as { [key: string]: any };
if (verifiedToken.target_url !== WEBHOOK_URL) {
return reject(`Token ${token} is invalid: target URL mismatch`);
}
resolve(verifiedToken);
} catch (e) {
reject(`Token ${token} is invalid: ${(e as Error).message}`);
}
});
});
}
Example
Let's go through a step-by-step example where we create a webhook, add a trigger, and then receive and verify a request. First, create a webhook with the API:
curl -X POST --location 'https://api.slashid.com/organizations/webhooks' \
--header 'SlashID-OrgID: <ORGANIZATION ID>' \
--header 'SlashID-API-Key: <API KEY>' \
--header 'Content-Type: application/json' \
--data '{
"target_url": "https://api.example.com/slashid-webhooks/authn-failed",
"name": "authentication failed",
"description": "Webhook to receive notifications when someone fails to authenticate",
"custom_headers": {
"X-EXAMPLE": ["something"]
}
}'
{
"result": {
"custom_headers": {
"X-EXAMPLE": [
"something"
]
},
"description": "Webhook to receive notifications when someone fails to authenticate",
"id": "16475b49-2b12-78d7-9012-cfe0e174dcd3",
"name": "authentication failed",
"target_url": "https://api.example.com/slashid-webhooks/authn-failed"
}
}
Note that in the response we get an id
field - we will use this for the next steps. Also note the target URL - it is
an HTTPS URL, with a path but no query parameters or fragments.
Now we will add a trigger to this webhook so that it is called whenever SlashID publishes an "authentication failed" event, using the webhook ID we received in the response:
curl -X POST --location 'https://api.slashid.com/organizations/webhooks/16475b49-2b12-78d7-9012-cfe0e174dcd3/triggers' \
--header 'SlashID-OrgID: <ORGANIZATION ID>' \
--header 'SlashID-API-Key: <API KEY>' \
--header 'Content-Type: application/json' \
--data '{
"trigger_type": "event",
"trigger_name": "AuthenticationFailed_v1"
}'
Our webhook is now ready to go. We can test this using the /test-events
endpoint, which can be used to publish
events for testing. We will publish just a single AuthenticationFailed_v1
event:
curl -X POST --location 'https://api.slashid.com/test-events' \
--header 'SlashID-OrgID: <ORGANIZATION ID>' \
--header 'SlashID-API-Key: <API KEY>' \
--header 'Content-Type: application/json' \
--data '[
{
"event_name": "AuthenticationFailed_v1"
}
]'
Your application will then receive a POST
request, which would be represented with cURL like so:
curl -X POST --location 'https://api.example.com/slashid-webhooks/authn-failed' \
--header 'X-EXAMPLE: something' \
--header 'Content-Type: application/jwt' \
--data 'eyJhbGciOi[...]JFUzIOLWcifQ.eyJhdWQiOiIwM[...]DDAwMDAiLCAiZXhwIjoxNjg1.ipfHZf9tSRbVT[...]hXOVZmZw0sKPIpOV'
The body is a signed and encoded webhook (here shortened for readability) - we can verify it using the verification JWKS which we retrieve using the SlashID API:
curl --location 'https://api.slashid.com/organizations/webhooks/verification-jwks' \
--header 'SlashID-OrgID: <ORGANIZATION ID>'
{
"keys": [
{
"alg": "ES256",
"crv": "P-256",
"key_ops": [
"verify"
],
"kid": "WOJ_pw",
"kty": "EC",
"use": "sig",
"x": "whqB4q7Jap4zxPr-dmdl7u3SsA7KrQ3aM",
"y": "Gc35SgXrAbImsiYLfl-M4hSgjGex22M"
}
]
}
As this is the public key, you do not need to provide the API key to retrieve the verification key.
You can use a library of your choice to parse the JWKS and use it to verify the JWT signature. The decoded JWT body:
{
"aud": "<ORGANIZATION ID>",
"exp": 1685463580,
"iat": 1685463280,
"iss": "https://api.slashid.com",
"jti": "9c206041-be4f-482b-a129-321498fd3343",
"sub": "68a850ca-b2ee-46ce-8592-410813037739",
"webhook_id": "0647620e-ae30-7d18-8800-cf6732b6b007",
"target_url": "https://api.example.com/slashid-webhooks/authn-failed",
"trigger_name": "AuthenticationFailed_v1",
"trigger_type": "event",
"trigger_content": {
"analytics_metadata": {
"analytics_correlation_id": "3b132de1-3b11-4546-a2fe-521287c4a592"
},
"authenticated_methods": ["email_link"],
"browser_metadata": {
"window_location": "https://example.com/login",
"user_agent": "mozilla"
},
"event_metadata": {
"is_test_event": true,
"event_id": "68a850ca-b2ee-46ce-8592-410813037739",
"event_name": "AuthenticationFailed_v1",
"event_type": "AuthenticationFailed",
"organization_id": "<ORGANIZATION ID>",
"source": 2,
"timestamp": "2023-05-30T16:14:40Z"
},
"failed_authn_method": "webauthn",
"failure_reason": "no platform authenticator available",
"person_id": "0647620e-b32f-727b-8004-3f29d8ec5520"
}
}
You can now check that the aud
, exp
, iss
, and target_url
fields are all as expected. Note that the sub
claim
is the same as the event_id
in the trigger_content
.
Having verified the request, you can now process the trigger content according to your application's needs. Note that
because we created this event using the /test-events
endpoint, the is_test_event
field in trigger_content
is
set to true
.