Skip to content
NovaroPayDocs
PSP-Version 2026-10-25.acacia
Theme
Merchant portal(opens in a new tab)

Signatures

Anyone can send a request to your endpoint’s URL. Check every request’s signature before you act on it: it proves the request is ours and that its body is as we sent it.

Every request carries a PSP-Signature header:

PSP-Signature: t=1760000000,v1=0ea76beaa2f76da27cbc5947a9772534287156d02b13d0ebb0d290a593cb9ecd
  • t is when we signed the request, in seconds since the Unix epoch. Each attempt is signed anew, so a retry has a new t.
  • v1 is the signature: HMAC-SHA256, in lowercase hex, of t, a . and the request’s raw body, with your endpoint’s signing secret, the whole whsec_… string, as the key.
  • While you roll the secret in the portal (until its Webhooks page opens, your account manager rolls it for you), the newest old secret keeps signing until the time you chose has passed: the header then has one v1 for each secret in force, two at most. A request is ours if any v1 matches.
  1. Take the body exactly as it came, before any JSON parsing: re-serialised JSON is other bytes.
  2. Split the header on ,, each part on its first =: keep t and every v1.
  3. Compute the HMAC-SHA256 of t + "." + body with your secret, and compare it with each v1 in constant time.
  4. Refuse a request whose t is more than 5 minutes from your clock, so a captured request can’t be sent again later.

Answer a request that fails with 400 and don’t act on it. Each example below does the whole check and is tested against a delivery we sign:

verify.go
package yourapp
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
)
// Tolerance is how old a delivery's timestamp may be: older ones are
// refused, so a captured request can't be replayed later.
const Tolerance = 5 * time.Minute
// Verify checks the PSP-Signature header of a webhook delivery against the
// raw request body, exactly as received, and your endpoint's signing
// secret (whsec_…). It returns nil when the body is ours and recent.
func Verify(body []byte, header, secret string, now time.Time) error {
var timestamp string
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch key {
case "t":
timestamp = value
case "v1":
signatures = append(signatures, value)
}
}
t, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || len(signatures) == 0 {
return errors.New("no timestamp or v1 signature in PSP-Signature")
}
if age := now.Sub(time.Unix(t, 0)); age > Tolerance || age < -Tolerance {
return errors.New("timestamp outside the tolerance")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(body)
expected := mac.Sum(nil)
// During a secret's roll the header has a v1 for each secret in force:
// any one of them that matches is enough.
for _, s := range signatures {
got, err := hex.DecodeString(s)
if err == nil && hmac.Equal(got, expected) {
return nil
}
}
return errors.New("no v1 signature matches")
}

This request is signed with whsec_8Kq3c2Vb7ZpXyN1mT4rLs6Hd9Jf0GwQe, and with an older secret in the same header, at t = 1760000000: your code should take it at that time, and refuse it with one byte of the body changed.

Header
PSP-Signature: t=1760000000,v1=06309820443c3a8d292dcd2956f6151b52cf7aa4df2f212f6391b36eab245adf,v1=0ea76beaa2f76da27cbc5947a9772534287156d02b13d0ebb0d290a593cb9ecd
Body
{"id":"evt_in_01K7XAMPLE00000000000000","object":"event","type":"payment.succeeded","api_version":"2026-10-14.acacia","created":1760000000,"livemode":false,"account":"macc_01K7XAMPLE00000000000000","data":{"object":{"id":"pay_in_01K7XAMPLE00000000000000","object":"payment","amount":49900,"currency":"INR","status":"succeeded"},"previous_attributes":{"status":"processing"}},"request":null,"pending_webhooks":1}

Keep the secret on your server only, never in a browser or an app. If it leaks, roll it at once, with no overlap: the old secret stops straight away. Until the portal’s Webhooks page opens, ask your account manager.