Webhooks
How SignCare notifies your server when asynchronous flows complete — what's sent, how to register URLs, how to verify signatures, and what to watch out for.
When webhooks are used
SignCare pushes webhook callbacks when an asynchronous flow reaches a terminal state. You don't poll — we POST to a URL you've registered. The flows that emit webhooks today:
- eSign — each signer's signature, a failed signing attempt, a rejection, expiry, revoke (see eSign payload)
- Aadhaar OVSE — offline Aadhaar verification completed
- DigiLocker — issued document fetch completed
- NeSL eStamp — e-stamp workflow completed
- Physical eStamp — physical stamp paper procurement order completed
- Template + SHCIL eStamp + eSign — the result of a template request that includes SHCIL stamp details (see Template + SHCIL eStamp + eSign payload)
- PDF Merge and Compress — merged/compressed PDF ready (or the request failed)
- ITR Fetch — the taxpayer's filed income tax returns, or why the fetch failed (see ITR Fetch payload). The outcome is usually already in the response to your submit call; the webhook is sent for every outcome anyway
- WealthSync — three separate streams covering consent lifecycle, financial data availability, and historical data fetch (see WealthSync webhooks)
Synchronous verifications (PAN, GST, Bank Account, IFSC, Voter ID, etc.) return the result in the HTTP response body and do not use webhooks.
Registering a webhook URL
Two mechanisms — a per-request URL and an account-level default. The rule is simple: if you pass a webhook URL in the request, we use it; otherwise we fall back to the account-level default configured for that API type. If neither is set, the flow still completes but no webhook is sent (fetch the result via the polling endpoint).
Per-request URL
Some flows accept a webhook URL directly in the request body when you start the flow. When present, it takes precedence over the account-level default for that single transaction — nothing is stored.
- eSign — pass
responseUrlin the create-sign-request body. - DigiLocker — pass
webhookUrlin the init body. - PDF Merge and Compress —
webhook_urlis required in the request body; there is no account-level default for this flow.
// eSign — create sign request
{
"documentInfo": { "...": "..." },
"userInfo": [ { "...": "..." } ],
"responseUrl": "https://yourapp.com/signcare/esign-callback"
}// DigiLocker — /api/v1/digilocker/init
{
"consent": "Y",
"consent_text": "I hereby declare my consent…",
"docs": [ "" ],
"webhookUrl": "https://yourapp.com/signcare/digilocker-callback",
"mobileNumber": "9999999999",
"using_aadhaar": false
}Leave the per-request field out (or empty) to use the account-level default instead. DigiLocker requires a resolvable URL from one of the two sources — if the request omits webhookUrl and no account-level default is configured for DigiLocker, init fails with a validation error asking you to provide one. (You can still retrieve results later via GET /api/v1/digilocker/details/{transactionId}.)
Account-level default (all flows)
Register a webhook URL per API type on the Webhooks tab in the SignCare portal (Configuration → Settings → Webhooks). Use this when all your traffic for an API should land at the same URL without passing it per request. There is no API endpoint to set this — it is managed entirely from the portal.
On the Webhooks tab you configure, for each API type:
- the delivery URL, set independently for Sandbox (test) and Production — flip the environment toggle in the topbar to edit the other side;
- the optional payload flag (Exclude base64 for eSign / Physical eStamp / NeSL eStamp / DigiLocker, or Include QR as base64 for Aadhaar OVSE).
Configurable API types: ESign, DigiLocker, NeSLEStamp, PhysicalEStamp, Aadhaar_OVSE. (WealthSync uses its own three-URL model — see WealthSync webhooks.)
Resolution order
For each outbound webhook we resolve the URL as:
- Per-request URL in the original request body, where the flow supports it — eSign
responseUrl, Template + SHCIL eStamp + eSignresponseUrl, DigiLockerwebhookUrl - The account-level webhook URL for that API type (configured on the portal's Configuration → Settings → Webhooks tab, per environment — sandbox and production are independent)
- If neither is set → for most flows the webhook is simply not sent (no error — the flow still completes, fetch the result via the polling endpoint). DigiLocker is stricter: init is rejected unless a URL is resolvable from step 1 or 2.
What gets sent
All webhook deliveries are HTTP POST with Content-Type: application/json. A few webhooks carry an x-signcare-event header that names the event — for example the Template + SHCIL eStamp + eSign webhook below.
If you have turned on signature verification for the environment, every delivery also carries an X-SignCare-Signature header that proves it came from SignCare. See Verifying webhook signatures. It is off until you turn it on, and the payload is the same either way.
Until you turn on signature verification, webhook payloads are unsigned. If your endpoint is public, treat an unsigned payload as untrusted and validate it against your own records (match the documentId / txnId / transactionId to a request you actually made). Do not act on a webhook as authoritative identity proof without reconciling against a fetched record.
eSign payload
The eSign webhook is the document's current status, the same information as POST /api/v1/esign/status. Keys are PascalCase at the top level and on each signer. The keys inside AadhaarAuditInfo are camelCase.
{
"Event": "SignerFailed",
"DocumentId": "a1b2c3d4-...",
"DocumentReferenceId": "YOUR-REFERENCE-ID",
"Content": "base64-encoded-pdf",
"DocumentStatus": "Pending",
"SignerInfo": [
{
"SignerRefId": "YOUR-SIGNER-REF-ID",
"SignerId": "uuid",
"SignerStatus": "Pending",
"InvitationExpireTimeStamp": "...",
"SignerName": "RAM KUMAR",
"AadhaarAuditInfo": {
"name": "RAM KUMAR",
"pincode": "411001",
"state": "MAHARASHTRA",
"yearOfBirth": "1990",
"gender": "M",
"title": "1234",
"serialNumber": "...",
"issuerName": "...",
"issuerOrganization": "...",
"validityStart": "...",
"validityEnd": "...",
"message": "{\"Title\":\"The eSign request could not complete due to mismatch of below details to the Aadhaar provided by the sender.\",\"Message\":[\"Birth Year : 1985\"],\"Code\":\"IDENTITY_MISMATCH\",\"OccurredAt\":\"2026-10-22T08:32:46.1234567Z\"}"
},
"Failure": {
"Code": "IDENTITY_MISMATCH",
"Reason": "The eSign request could not complete due to mismatch of below details to the Aadhaar provided by the sender. Birth Year : 1985",
"OccurredAt": "2026-10-22T08:32:46.1234567Z"
}
}
]
}Fields that have no value are left out (for example RejectReason, Failure, Event on older deliveries).
Event says what triggered the delivery:
| Event | When |
|---|---|
DocumentSent | A document created with uiMode is proceeded and sent to the signers |
SignerSigned | A signer signed; other signers are still pending |
DocumentSigned | The last signer signed; DocumentStatus is Signed |
SignerFailed | A signing attempt did not produce a signature; see Failure. The signer stays Pending and can try again |
SignerRejected / SignerApproved | A signer declined the document, or a reviewer approved it |
SignerExpired | A signer's invitation expired; other signers are still pending |
DocumentExpired | Every signer's invitation expired; DocumentStatus is Expired |
DocumentRevoked | The document was revoked |
Treat DocumentStatus and SignerStatus as the source of truth and Event as the reason for the delivery.
Status values. DocumentStatus: Pending, Signed, Rejected, Expired, Revoked. SignerStatus: Pending, Signed, Rejected, Approved, Expired. (The status API also returns Waiting for a sequential signer whose turn has not come.)
Failure is present while a signer is Pending after a failed attempt, and is cleared when the signer signs. Match on Code, never on Reason:
Code | Meaning |
|---|---|
IDENTITY_MISMATCH | The signer's Aadhaar details (name, last 4 digits, birth year, gender, pincode or state) do not match the details you sent. Each mismatch is listed in Reason. The signature is discarded |
PROVIDER_ERROR | The eSign provider reported a failure, for example a wrong OTP, too many OTP attempts, or the signer cancelled on the provider's page. ProviderErrorCode carries the provider's own code when it sent one |
PROVIDER_UNAVAILABLE | No eSign provider could start the signing. The signer can try again later |
FACE_AUTH_FAILED | Aadhaar Face eSign did not complete on the mobile app (reported when the app sends the error, or when the provider returns a failed result) |
SIGNED_DOCUMENT_NOT_PRODUCED | The eSign was completed, but the signed document could not be produced. Contact support with the document ID |
Failure fields: Code, Reason, ProviderErrorCode (when the provider sent one) and OccurredAt (UTC, ISO 8601).
Not every case can be reported. If the signer closes the page without finishing, or a provider does not send its result back, there is no failure to report: the signer stays Pending until they sign or the invitation expires. Wrong OTP, too many attempts and cancel are reported as PROVIDER_ERROR; tell them apart with ProviderErrorCode where the provider sends one.
Aadhaar OVSE payload
{
"success": true,
"statusCode": 200,
"message": "Verification successful",
"request_id": "YOUR-REQUEST-ID",
"data": {
"txn_id": "ovse-txn-id",
"pending": false,
"verification_passed": true,
"verified_claims": {
"residentName": "RAM KUMAR",
"dob": "1990-01-15",
"gender": "M",
"address": "123, MG Road, Pune",
"pincode": "411001"
},
"verified_at": "2026-04-22T10:18:30Z"
}
}On failure success is false, verification_passed is false, and data.message contains the reason.
DigiLocker payload
{
"success": true,
"statusCode": 200,
"message": "Documents fetched",
"request_id": "YOUR-REQUEST-ID",
"data": {
"transactionId": "digilocker-txn-id",
"docs": [
{
"docType": "ADHAR",
"status": "Fetched",
"fetchedAt": "2026-04-22T10:20:00Z",
"dataPdf": "base64-encoded-pdf",
"issued": {
"name": "Aadhaar Card",
"type": "ADHAR",
"size": "102400",
"date": "2020-01-01",
"uri": "in.gov.uidai-ADHAR-xxxxxx",
"doctype": "ADHAR",
"issuer": "UIDAI"
},
"details": {
"name": "RAM KUMAR",
"dob": "1990-01-15"
}
}
]
}
}NeSL eStamp payload
{
"success": true,
"statusCode": 200,
"message": "NeSL Webhook Update",
"data": {
"eStampId": "nesl-stamp-id",
"workflowStatus": "Completed",
"signatoriesInfo": [
{
"emailID": "ram@example.com",
"name": "RAM KUMAR",
"status": "Completed",
"remarks": "",
"signType": "Aadhaar",
"signedDate": "2026-04-22T10:30:00Z"
}
],
"fileList": [
{
"documentName": "estamp-certificate.pdf",
"base64FileData": "base64-encoded-pdf"
}
]
}
}Physical eStamp payload
Sent when a Physical eStamp order reaches a terminal state (procurement completed or failed). The payload carries the order ID and an eStamps array with one entry per stamp paper in the order — most orders have a single entry, bulk/series orders have many.
{
"orderId": "physical-estamp-order-id",
"eStamps": [
{
"eStampId": "abc123-uuid",
"firstPartyName": "ACME PRIVATE LIMITED",
"secondPartyName": "RAM KUMAR",
"stampDutyPaidBy": "First Party",
"stampDutyValue": 500,
"articleId": 5,
"considerationPrice": 100000,
"branchId": 12,
"purposeOfStampDuty": "Lease Agreement",
"status": "Completed",
"documentId": "linked-document-uuid",
"data": "base64-encoded-stamp-pdf",
"stateCode": "MH",
"stampPaperNumber": "MH2025-XXXXXXXX"
}
]
}status is the per-stamp status. data is the base64-encoded stamp paper PDF — omit it from the payload by setting the per-company Exclude Base64 flag for PhysicalEStamp (see your account manager). When excluded, fetch the PDF later via GET /api/v1/PhysicalEStamp/stamp-details/{orderId}.
Template + SHCIL eStamp + eSign payload
Sent when a POST /api/v1/Template/request that includes shcilStampInfo finishes — successfully or not. The request itself answers 202 Accepted straight away; this webhook carries the outcome. The x-signcare-event header tells you which one it is:
x-signcare-event | Meaning |
|---|---|
TEMPLATE_ESTAMP_ESIGN_CREATED | The stamp was issued and the signing document was created. data holds the document, the esignLink of each signer, and the stampDetails. |
TEMPLATE_ESTAMP_ESIGN_FAILED | The request failed after being accepted. success is false and message gives the reason. data holds your reference ids, status: "FAILED", and stampDetails if a stamp had already been issued. |
{
"data": {
"documentId": "01a13924-c53e-7730-a227-e04af1adfa17",
"documentReferenceId": "YOUR-REFERENCE-ID",
"responseTimeStamp": "14-10-2026 06:41:13",
"userInfo": [
{
"userRefId": "TEMPLATE-SIGNER-POSITION-ID",
"userId": "uuid",
"docSignerId": "uuid",
"esignLink": "https://sign.example.com/sign/..."
}
],
"uiModeURL": "",
"stampDetails": {
"eStampId": "uuid",
"reconMappingId": "SCABCDEFGHIJKLMNOPQR",
"userDocumentId": 120886,
"certId": "IN-AS00363558894070Y",
"urn": "AS1004DIGID2026287376478ASAC",
"certificateNo": "IN-AS00363558894070Y",
"certificateIssuedDate": "14-Oct-2026 12:11 PM"
}
},
"success": true,
"statusCode": 200
}The failure body uses the same envelope with success: false, a statusCode and message for the reason, and a smaller data (documentReferenceId, reconMappingId, status, and stampDetails when relevant). Match every delivery to your request by data.documentReferenceId.
If this webhook does not reach you, POST /api/v1/Template/request/status returns the identical body. See the Template Management API for the full flow.
PDF Merge and Compress payload
Sent when a POST /api/v1/pdf/process request finishes. event is pdf.process.completed or pdf.process.failed. On success, data.pdf carries the result PDF as base64 — your endpoint must accept request bodies up to about 70 MB.
{
"event": "pdf.process.completed",
"request_id": "8f1c2e4a-0000-0000-0000-000000000000",
"reference_id": "LOAN-12345",
"status": "completed",
"data": {
"file_name": "loan_kit.pdf",
"pdf": "base64-encoded-pdf",
"page_count": 14,
"file_count": 3,
"input_size_bytes": 24500000,
"output_size_bytes": 6100000,
"reduction_percent": 75.1,
"merged": true,
"compression_applied": "medium",
"warnings": []
}
}On failure there is no data; error.message says what went wrong. Submit a new request with a new reference_id.
This webhook is retried on a shorter schedule than most flows: after 30 seconds, 2 minutes and 10 minutes (4 attempts in total). If every attempt fails, the PDF can still be collected for 1 hour from GET /api/v1/pdf/process/{request_id} (status delivery_failed). The file is deleted as soon as a webhook attempt succeeds, so save it on the first delivery.
ITR Fetch payload
Sent when a POST /api/v1/itr/requests request has an outcome. The x-signcare-event header is itr.fetch.completed or itr.fetch.failed. The body is the same envelope the status API returns, plus an event_id that stays the same on every retry of that delivery, so use it to ignore a repeat.
You may already know the outcome. The submit call is held open for up to about 25 seconds. If the fetch finishes in that time, the outcome is in the submit response itself: 200 with the result, or the failure with its HTTP status (for example 400 INVALID_CREDENTIALS). The webhook is still sent, so treat the first of the two you see as the answer and ignore the other. If the fetch is still running, the submit call answers 202 and the webhook (or polling) carries the outcome.
{
"event_id": "5d0c7a52-3b1e-4f6a-9c1d-2a7e8b9f0c11",
"data": {
"request_id": "3f1c9b7e-6a52-4d1e-9a0b-2d7c5e8f1a44",
"reference_id": "LOAN-12345",
"status": "FAILED",
"error_code": "INVALID_CREDENTIALS"
},
"success": false,
"statusCode": 400,
"message": "The user ID or password was not accepted by the income tax portal. Check them and submit again with a new reference_id.",
"messages": [],
"request_id": "3f1c9b7e-6a52-4d1e-9a0b-2d7c5e8f1a44"
}On success data.status is COMPLETED, statusCode is 200 and data.result holds the filed returns keyed by financial year (years_found, years_downloaded and years_missing say what was and was not available). The error codes are in ITR Fetch errors.
If the income tax portal blocks our login, the request is not failed at once: it stays PROCESSING and is retried automatically. The status API then shows a notice and retry_at. No webhook is sent for the delay, only for the final outcome.
Retries and polling. A failed delivery is retried after 30 seconds, 2 minutes and 10 minutes (4 attempts in total). The result stays readable from the status API for 7 days whether or not a delivery succeeded. GET /api/v1/itr/requests/{request_id} also returns webhook_status (PENDING, RETRYING, DELIVERED or FAILED) and webhook_attempts, so if no webhook reached you, you can see why. Poll after 30 seconds, 1 minute and 5 minutes, then every 15 minutes.
WealthSync webhooks
WealthSync uses a different registration model from the other flows. Instead of the portal Webhooks tab or a per-request responseUrl, you provide three callback URLs to SignCare and we configure them on your account.
Share the three URLs with your SignCare onboarding contact:
| Stream | Purpose |
|---|---|
| Consent | Status changes across the consent lifecycle (Accepted, Rejected, Revoked, Expiring Soon, Expired) |
| Data | Financial information availability (raw + analysed) |
| Historical Data | Historical data fetch completion for eligible FI types |
All three deliveries are HTTP POST with Content-Type: application/json and follow the same retry / response semantics as the other flows below.
Consent webhook
Notifies you of changes in consent status. Sent once when the consent transitions to Accepted, Rejected, Revoked, Expiring Soon, or Expired.
{
"uniqueIdentifier": "63544833xx",
"consentHandle": "b407380f-1ec7-4e7e-bd43-997175d19c80",
"status": "ACTIVE",
"consentId": "6f08ad40-6f3e-49ee-8b53-6490b4214142",
"consentStartDate": "2025-05-23 12:05:00",
"consentEndDate": "2025-05-23 12:05:00",
"accounts": [
{
"accountId": "6f08ad40-6f3e-49ee-8b53-6490b4214142",
"fiType": "DEPOSIT",
"fipId": "fip@cdsl | fip@pnb",
"maskedAccountNumber": "XXXXXXXXXXXXX1234"
}
],
"userSessionId": "string"
}Data webhook
Notifies you when financial information is available. The same flow emits two notifications: once when raw data is ready, and once when analysed data is ready. The dataType field distinguishes the two, and dataOrigin identifies the source (e.g. Account Aggregator, MFC).
{
"uniqueIdentifier": "63544833xx",
"consentId": "6f08ad40-6f3e-49ee-8b53-6490b4214142",
"consentHandle": "b15b7443-841b-4bb8-8e46-17622dbdce83",
"sessionId": "eea80a67-3499-4f26-9231-5e938382c6c4",
"accountId": "b407380f-1ec7-4e7e-bd43-997175d19c80",
"fiType": "DEPOSIT",
"fiDataStatus": "SUCCESS",
"dataType": "RAW",
"dataOrigin": "AA",
"fipId": "string",
"desc": "string",
"errMsg": "string",
"errStage": "string",
"fiRequestTrigger": "USER_INITIATED",
"maskedAccountNumber": "XXXXXXXXXXXX0108",
"txnId": "string"
}dataType is RAW or ANALYSED. fiDataStatus indicates whether the data fetch succeeded; on failure, errMsg and errStage carry the diagnostic.
Historical Data webhook
Notifies you that historical data fetch has completed for an eligible FI type. Payload carries the consent/session identifiers, account, status, and the historical date range that was fetched.
{
"uniqueIdentifier": "63544833xx",
"accountId": "b407380f-1ec7-4e7e-bd43-997175d19c80",
"consentId": "6f08ad40-6f3e-49ee-8b53-6490b4214142",
"status": "HISTORICAL_DATA_FETCH_SUCCESS, HISTORICAL_DATA_FETCH_PARTIAL_SUCCESS, HISTORICAL_DATA_FETCH_FAILED",
"fromDate": "2023-01-01",
"toDate": "2023-12-31",
"fiType": "DEPOSIT",
"consentHandleId": "b15b7443-841b-4bb8-8e46-17622dbdce83",
"maskedAccountNumber": "XXXXXXXXXXXXX1234",
"fipId": "fip@cdsl | fip@pnb"
}status is one of HISTORICAL_DATA_FETCH_SUCCESS, HISTORICAL_DATA_FETCH_PARTIAL_SUCCESS, or HISTORICAL_DATA_FETCH_FAILED.
Verifying webhook signatures
Signature verification lets your server confirm that a webhook really came from SignCare and was not changed on the way, so nobody can fake a "document signed" or "eStamp issued" call to your endpoint. It is optional and off by default. Nothing changes for your integration until you turn it on.
Turn it on
- In the SignCare portal, open Configuration → Settings → Webhooks.
- Choose the environment with the topbar toggle. Sandbox (test) and Production are set up separately, so you can try it in Sandbox first.
- Under Signature verification, select Enable. Your signing secret is shown once. Copy it into your server's secret store; SignCare cannot show it again.
- Deploy the verification code below, then trigger a test flow.
The secret covers every webhook that environment sends to you: eSign, Template + SHCIL eStamp + eSign, Aadhaar OVSE, DigiLocker, NeSL eStamp, Physical eStamp, PDF Merge and Compress, and the three WealthSync streams.
Once you have turned signing on, reject any delivery that arrives without a valid X-SignCare-Signature instead of processing it. SignCare retries a rejected delivery on the normal schedule, so a one-off problem doesn't lose the event.
Rotate issues a new secret and the old one stops working at once, so update your server straight away. Disable turns signing off again, and deliveries go back to unsigned.
Turning signing on only adds two headers. The URL, the JSON body and the other headers stay exactly the same, so an endpoint that doesn't check the signature yet keeps working.
Headers
| Header | Value |
|---|---|
X-SignCare-Timestamp | When the delivery was signed, in Unix seconds |
X-SignCare-Signature | t=<timestamp>,v1=<signature> |
v1 is the lowercase hex HMAC-SHA256 of the string <timestamp>.<raw request body>, keyed with your signing secret. Every delivery attempt is signed again with a fresh timestamp, so a retry carries a different signature from the first attempt.
Verify a delivery
- Read the raw request body exactly as received, before any JSON parsing. Re-serialising the JSON changes the bytes and the check fails.
- Take
tandv1fromX-SignCare-Signature. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as lowercase hex, and compare it withv1using a constant-time comparison. - Reject the delivery if
tis more than 5 minutes from your server's clock. This stops an old delivery being replayed. - If either check fails, answer
401and don't process the payload.
// Node.js (Express): keep the raw body for this route
const crypto = require('crypto');
app.post('/signcare/callback', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-SignCare-Signature') || '';
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const t = parts.t, v1 = parts.v1 || '';
const expected = crypto
.createHmac('sha256', process.env.SIGNCARE_WEBHOOK_SECRET)
.update(`${t}.${req.body.toString('utf8')}`)
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300;
const valid = v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!fresh || !valid) return res.status(401).send('invalid signature');
res.status(200).send('OK');
queueJob('process-signcare-event', JSON.parse(req.body.toString('utf8')));
});// C# (ASP.NET Core)
[HttpPost("signcare/callback")]
public async Task<IActionResult> Callback()
{
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync();
var parts = Request.Headers["X-SignCare-Signature"].ToString()
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(p => p.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1)
|| !long.TryParse(t, out var ts)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
return Unauthorized();
var key = Encoding.UTF8.GetBytes(_signingSecret);
var expected = Convert.ToHexString(
HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{t}.{rawBody}"))).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(v1)))
return Unauthorized();
// enqueue rawBody for processing
return Ok();
}Keep the signing secret on your server only. Never put it in a browser or mobile app, a URL or your logs. If it leaks, Rotate it in the portal straight away.
Responding to a webhook
Your endpoint must respond with any 2xx status code. SignCare does not inspect the response body — status code is the only signal.
app.post('/signcare/callback', express.json(), (req, res) => {
// Acknowledge immediately — don't block on processing
res.status(200).send('OK');
// Process asynchronously (enqueue to a job queue, etc.)
queueJob('process-signcare-event', req.body);
});Retry behavior
Failed deliveries are retried automatically. If your endpoint returns non-2xx, times out, or is unreachable, SignCare tries again after 30 seconds, 2 minutes, 10 minutes, 1 hour and 5 hours — up to six attempts over roughly six hours. DigiLocker webhooks use a shorter schedule (30 seconds, 90 seconds, 3 minutes). PDF Merge and Compress also uses a shorter schedule (30 seconds, 2 minutes, 10 minutes; 4 attempts in total, see its payload section), and so does ITR Fetch (see its payload section). After the last attempt the delivery is not tried again, and there is no dead-letter storage.
Retries cover short outages, not a long one. Your architecture should still:
- Not rely solely on the webhook. Use the polling endpoint (below) as a safety net for reconciliation.
- Keep your webhook endpoint highly available. Avoid synchronous work inside the handler; queue for processing and 2xx immediately.
- Monitor for missed events. Periodically poll pending transactions that should have completed by now.
Timeout: SignCare waits up to 30 seconds for your endpoint to answer. A slower answer counts as a failed delivery and is retried. Keep your 2xx response under a few seconds to be safe.
Idempotency on your side
A delivery can arrive more than once — SignCare retries when your endpoint did not answer with a 2xx in time, even if it had already processed the first attempt. Dedupe using the transaction ID:
| Flow | Dedup key |
|---|---|
| eSign | None needed: one document gets several deliveries (one per event), and each carries the full current status. Apply each delivery as a status update; a repeated delivery changes nothing |
| Aadhaar OVSE | data.txn_id |
| DigiLocker | data.transactionId |
| NeSL eStamp | data.eStampId |
| Physical eStamp | orderId (or per-stamp eStamps[].eStampId for bulk orders) |
| Template + SHCIL eStamp + eSign | data.documentReferenceId |
| ITR Fetch | event_id (the same on every retry of that event) |
For WealthSync, dedup using the combination of identifiers that uniquely describe an event in your integration — e.g. consentId + status for Consent webhooks, txnId for Data webhooks, and consentId + accountId + fromDate + toDate for Historical Data webhooks. Confirm the exact contract with your SignCare onboarding contact before relying on a single field.
Store the ID in a table with a unique constraint and drop duplicate deliveries that arrive on a retry.
The request_id field (OVSE and DigiLocker payloads only) echoes back whatever correlation ID you sent on the original request — useful for matching the webhook back to the call site in your logs.
Polling — the recommended complement
Most flows expose a polling endpoint. Use it for reconciliation, for first-time integration testing, and as a fallback when webhooks are undeliverable.
| Flow | Polling endpoint |
|---|---|
| eSign | POST /api/v1/esign/status with documentId in body |
| Aadhaar OVSE | GET /api/v1/aadhaar-ovse/result/{txnId} |
| DigiLocker | GET /api/v1/digilocker/details/{transactionId} |
| NeSL eStamp | Webhook only — no polling endpoint currently |
| Physical eStamp | GET /api/v1/PhysicalEStamp/stamp-details/{orderId} |
| Template + SHCIL eStamp + eSign | POST /api/v1/Template/request/status with documentReferenceId or reconMappingId in the body |
| ITR Fetch | GET /api/v1/itr/requests/{request_id}, or GET /api/v1/itr/requests?reference_id=.... Also shows webhook_status |
| WealthSync | See the WealthSync API reference for fetch endpoints per FI type |
Polling endpoints return the same data shape as the webhook payload (or a pending: true stub if the flow hasn't completed yet).
Source IPs
Webhook traffic originates from SignCare's hosting infrastructure. We do not publish a stable list of source IPs today — allowlist by hostname if your firewall supports it, or contact support for a current snapshot.
Testing webhooks locally
During integration against Stage (https://uat-ext.signcare.io):
- Expose your local handler publicly with ngrok, localtunnel, or Cloudflare Tunnel.
- Register the tunnel URL either in the request body (eSign
responseUrl, DigiLockerwebhookUrl) or on the portal's Configuration → Settings → Webhooks tab (set it for the Sandbox environment). - Trigger the flow from your app against Stage — your tunnel receives the POST.
- Use webhook.site if you only want to inspect the payload shape without wiring a real handler.
What we're adding later
These are on the roadmap — not live yet. Do not build against them:
- Dead-letter storage for failed deliveries
- Per-delivery event ID for strict dedup
- A webhooks dashboard in the portal for delivery history + manual replay
When any of these ship, this page will be updated and we'll email your registered technical contact.
See also
- Environments — Stage vs Live URLs
- Embedded Signing — the in-browser completion signal when signing inside your app
- Idempotency — request-side correlation IDs
- Error Codes — for responses on the polling endpoints