How to authenticate SePay webhooks

Compare 4 SePay webhook authentication methods (HMAC-SHA256, API Key, OAuth 2.0, or none) with full PHP, Node.js, and Python code samples.

||

SePay supports 4 authentication methods. You pick one when creating the webhook and can change it later at any time.

MethodSecurityDifficultyWhen to use
NoneLowEasyTesting only, never production
API KeyMediumEasyBasic auth requirement
HMAC-SHA256HighMediumRecommended. Detects payload tampering in transit
OAuth 2.0HighHighWhen you already have an OAuth server

None

There's no authentication step. SePay sends the webhook straight to your URL without any security header, and your server has no way to verify whether the request really came from SePay.

Use this only when testing in an internal environment. Never use it in production, because anyone who knows the URL can spoof requests to your endpoint.

API Key

SePay includes an Authorization header on every request. Your server compares the value against the API Key you configured to decide whether to accept the request.

Code
1
Authorization: Apikey YOUR_API_KEY

Verification code

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
$expected = getenv('SEPAY_API_KEY');
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
 
if (!str_starts_with($auth, 'Apikey ') || !hash_equals($expected, substr($auth, 7))) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
exit;
}
 
$payload = json_decode(file_get_contents('php://input'), true);
// ... handle ...
echo json_encode(['success' => true]);

Configuration

Pick API Key in the Security step when creating the webhook, enter the key, store it in an environment variable on your server.

API Key configuration
Click to expand
Configure API Key in the wizard
API Key shown in full only once

After saving, only the last 4 characters are shown (****xxxx). SePay does not store the plaintext. Copy it into your environment variable right when you create it. If you forget or suspect a leak, generate a new API Key.

API Key authenticates the sender but doesn't guarantee the data wasn't modified in transit. For stronger security use HMAC-SHA256.

HMAC-SHA256

The most secure option. SePay signs each request, your server reconstructs the signature to verify.

Headers SePay sends

X-SePay-Signaturestring
Signature, format sha256={hex_hash}
X-SePay-Timestampstring
Unix timestamp (seconds) at sign time

How SePay signs

  1. Get the current timestamp (Unix seconds)
  2. Concat: {timestamp}.{raw_body}
  3. Compute HMAC-SHA256 with the Secret Key
  4. Send header X-SePay-Signature: sha256={hex_hash}
  5. Send header X-SePay-Timestamp: {timestamp}
Use the raw body, not the parsed body

SePay signs the raw bytes of the body. If middleware (express.json(), Fastify default, ...) parsed the body and you JSON.stringify(req.body) it back, the signature won't match because:

  • PHP escapes Unicode to \uXXXX, JavaScript doesn't
  • JSON key order can change
  • Whitespace can differ

How to read the raw body: Node.js express.raw({ type: 'application/json' }), PHP file_get_contents('php://input'), Python request.get_data(as_text=True).

Webhooks with form-encoded body

If the webhook is configured for application/x-www-form-urlencoded or multipart/form-data, SePay signs the form-encoded string (like PHP's http_build_query). Still use the raw body.

Signature verification code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
$signature = $_SERVER['HTTP_X_SEPAY_SIGNATURE'] ?? '';
$timestamp = (int) ($_SERVER['HTTP_X_SEPAY_TIMESTAMP'] ?? 0);
$body = file_get_contents('php://input');
$secret = getenv('SEPAY_WEBHOOK_SECRET');
 
// Anti-replay: reject timestamps off by more than 5 minutes
if (abs(time() - $timestamp) > 300) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Request expired']);
exit;
}
 
// Reconstruct the signature and compare in constant time
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Invalid signature']);
exit;
}
 
$payload = json_decode($body, true);
// ... handle transaction ...
echo json_encode(['success' => true]);

Configuration

  1. Create/edit the webhook, pick HMAC-SHA256 in the Security step
  2. Enter a Secret Key or click the auto-generate button
  3. Store the Secret Key in an environment variable (SEPAY_WEBHOOK_SECRET)
HMAC-SHA256 configuration
Click to expand
Configure HMAC-SHA256 in the wizard
Handle the Secret Key carefully

After saving, only the last 4 characters are shown (****xxxx) — SePay does not store the plaintext. Copy it into your environment variable right when you create it. Don't commit to source code, don't send over email or chat. If you forget or suspect a leak, generate a new Secret Key.

Common errors

IssueCauseFix
Signature mismatchMiddleware parses then re-serializes the bodyUse the raw body
Signature mismatchWrong Secret KeyCheck the env var
Signature mismatchMissing timestamp in the signed stringUse format {timestamp}.{body}
Timestamp too oldServer clock driftEnable NTP for auto time sync

OAuth 2.0

Flow: SePay calls your token endpoint to fetch an access token, then sends webhooks with header Authorization: Bearer {access_token}. Tokens approaching expiration are refreshed or re-issued automatically.

OAuth 2.0 configuration
Click to expand
Configure OAuth 2.0 in the wizard

Two formats supported

StandardCustom
Requestapplication/x-www-form-urlencodedapplication/json
Bodygrant_type=client_credentials{"clientId": "...", "clientSecret": "..."}
Response{"access_token": "...", "expires_in": 3600}{"data": {"accessToken": "...", "expiredIn": 3600}}

When fetching a token, SePay tries Standard first. If the response doesn't match, it falls back to Custom. New integrations should use Standard; existing Custom users don't need to migrate.

Standard

OAuth 2.0 client credentials. Most frameworks support this out of the box. SePay sends a request to your token endpoint:

>
>
>
>
curl -X POST https://your-server.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
-d "grant_type=client_credentials"

Body params (application/x-www-form-urlencoded):

grant_typestringrequired
Always client_credentials
client_idstringrequired
Client ID configured on Webhooks
client_secretstringrequired
Client Secret configured on Webhooks

Response:

RESPONSE
{
  "access_token": "eyJhbGci...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "dGhpcyBpcyBh..."
}
access_tokenstringrequired
Token SePay uses in Authorization: Bearer {access_token}
expires_ininteger
Expiration (seconds). Defaults to 3600 if omitted
refresh_tokenstring
Refresh token. If absent, SePay requests a fresh token from scratch

Sample server endpoint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$grantType = $_POST['grant_type'] ?? '';
 
if ($grantType !== 'client_credentials') {
http_response_code(400);
echo json_encode(['error' => 'unsupported_grant_type']);
exit;
}
 
$expected = 'Basic ' . base64_encode(getenv('CLIENT_ID') . ':' . getenv('CLIENT_SECRET'));
if (!hash_equals($expected, $auth)) {
http_response_code(401);
echo json_encode(['error' => 'invalid_client']);
exit;
}
 
echo json_encode([
'access_token' => bin2hex(random_bytes(32)),
'token_type' => 'Bearer',
'expires_in' => 3600,
]);

Custom

Backward-compatible for older integrations. Don't migrate if it's working.

>
>
>
curl -X POST https://your-server.com/oauth/token \
-H "Content-Type: application/json" \
-d '{"clientId":"YOUR_CLIENT_ID","clientSecret":"YOUR_CLIENT_SECRET"}'

Body params (application/json):

clientIdstringrequired
Client ID configured on Webhooks
clientSecretstringrequired
Client Secret configured on Webhooks

Response:

RESPONSE
{
  "data": {
    "accessToken": "eyJhbGci...",
    "refreshToken": "eyJhbGci...",
    "expiredIn": 3600
  }
}
data.accessTokenstringrequired
Access token
data.refreshTokenstringrequired
Refresh token
data.expiredInintegerrequired
Expiration (seconds)

Refresh Token

When the token has less than 10 seconds left, SePay refreshes. If refresh fails, SePay requests a fresh token from scratch.

Standard: grant_type=refresh_token + refresh_token form-urlencoded.

Custom: JSON {"clientId": "...", "clientSecret": "...", "refreshToken": "..."}.

SePay tries Standard first, falls back to Custom on failure.

Sending the webhook

Once SePay has the token:

POST https://your-webhook-url
Authorization: Bearer eyJhbGci...
Content-Type: application/json

The endpoint must return 200/201 with {"success": true} to count as success. Anything else is treated as a failure.

Notes

  • The token endpoint must be HTTPS
  • A failing token endpoint retries on the same webhook delivery schedule, see Error handling
  • OAuth auth failing and webhook not arriving? See OAuth 2.0 diagnostics
Client Secret shown in full only once

Reopening only shows the last 4 characters (****xxxx). SePay does not store the plaintext. Copy it right when you create it. If you forget, generate a new Client Secret (Client ID stays the same).

Next