hamada-emam-tech / esign-sadq
SADQ (sadq.sa) driver for hamada-emam-tech/esign: Saudi e-signature envelopes with Nafath identity.
Requires
- php: ^8.2
- ext-json: *
- hamada-emam-tech/esign: ^0.3
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
None
Suggests
- guzzlehttp/guzzle: A PSR-18 HTTP client, picked up automatically
- symfony/http-client: A PSR-18 HTTP client, picked up automatically
Provides
None
Conflicts
None
Replaces
None
README
SADQ driver for hamada-emam-tech/esign: Saudi e-signature envelopes, with Nafath identity.
vendor/bin/esign add sadq # or: composer require hamada-emam-tech/esign-sadq
The core discovers the driver on install. You also need a PSR-18 HTTP client: Guzzle and Symfony HttpClient are picked up automatically.
Configure
$esign = new Esign([ 'default' => 'sadq', 'providers' => [ 'sadq' => [ 'environment' => 'production', // or 'sandbox' (the default) 'account_id' => getenv('SADQ_ACCOUNT_ID'), 'account_secret' => getenv('SADQ_ACCOUNT_SECRET'), 'username' => getenv('SADQ_USERNAME'), 'password' => getenv('SADQ_PASSWORD'), 'client_secret' => getenv('SADQ_CLIENT_SECRET'), 'webhook_id' => getenv('SADQ_WEBHOOK_ID'), ], ], ]);
| Option | Default | Notes |
|---|---|---|
environment |
sandbox |
sandbox → https://api-sandbox.sadq-sa.com, production → https://apigw.sadq.sa |
base_url |
from environment |
Overrides environment. |
account_id, account_secret, username, password, client_secret |
required | Issued by SADQ for the integration account. |
client_id |
Integrationclient |
|
webhook_id |
none | Set it. Without it, SADQ notifies the account's default webhook, which on a shared account may be someone else's server. |
webhook_token |
none | The HeaderToken you registered. Checked only when webhook_verification is token. |
webhook_header |
any header | Name of the header that carries the token, to check only that one. |
webhook_verification |
none |
token refuses webhooks that don't carry webhook_token (see Webhook verification). |
invitation_language |
1 |
SADQ's language code for invitation emails. |
expires_in_days |
30 |
How long invitations stay open. |
http_client, request_factory, stream_factory |
auto-detected | PSR-18 / PSR-17 objects. |
cache |
none | A PSR-16 cache, to share the access token across requests and workers. |
Send
use HamadaEmamTech\Esign\Envelope\{Envelope, Document, Signer, SignatureField}; $sent = $esign->send(new Envelope( documents: [Document::fromPath('contract.pdf')], signers: [ Signer::nafath('1012345678', 'Ahmed Ali', 'ahmed@example.sa', order: 1, fields: [new SignatureField(page: 1, x: 60, y: 700)]), Signer::email('Sara', 'sara@example.sa', order: 2), // no fields: signs by agreeing ], subject: 'Please sign the agreement', metadata: ['reference' => 'contract-42'], )); $sent->envelopeId; // store this: it is the only ID you need $sent->signingUrls; // ['ahmed@example.sa' => 'https://…/sign/…', …]. Open in a new tab, not an iframe.
Webhook, then status, then download
// POST /webhooks/sadq $result = $esign->handleWebhook($request->headers->all(), $request->getContent(), 'sadq'); if ($result->isValid()) { dispatch(new SyncEnvelope((string) $result->ref())); // do the rest in a queue } return response($result->response->body, $result->response->status); // always 200 // In the job: $status = $esign->status($ref); // "sadq:…", stored from $sent->ref() if ($status === EnvelopeStatus::Signed) { $pdf = $esign->download($ref); }
Status mapping
| SADQ | EnvelopeStatus |
|---|---|
In-progress, nobody you invited has signed |
Sent |
In-progress, some of your signers have signed |
PartiallySigned |
Completed, and every signer you invited shows SIGNED |
Signed |
Completed, but not every invited signer signed (seen live on an envelope with no signers) |
Sent / PartiallySigned |
Voided, Cancelled |
Cancelled |
Rejected, Declined |
Declined |
Expired |
Expired |
| anything else | throws ProviderError. Please report it. |
In-progress (statusValue 1), Voided (2) and Completed (5) were seen on the sandbox across 192 envelopes. Rejected, Declined and Expired follow SADQ's naming and haven't been seen live yet. Signatory statuses seen: PENDING and SIGNED.
Webhook verification
SADQ's webhook body is {"RequestId": "<envelope id>", "Status": 1|2|3}. SADQ doesn't sign it, so by default the driver accepts any webhook (webhook_verification: none). That's safe because the driver never trusts the body: parseWebhook() only extracts the envelope ID, and status() asks SADQ's API. A fake call can at most cause one status lookup and can never mark an envelope signed. Rate-limit the route.
You can register a webhook with a HeaderToken (registerWebhook($url, $token)). SADQ's delivery log shows those deliveries succeeding, but it doesn't show whether the token is forwarded, or in which header. Once you've confirmed it on a real delivery, set 'webhook_verification' => 'token' and webhook_token (plus webhook_header to check one header only). In that mode the driver fails closed.
Capabilities and optional features
| SADQ | |
|---|---|
| Identity methods | email, nafath |
| Documents per envelope | 1 |
| Signing order, field placement, signing links | yes |
remind(), extendDeadline(), details(), webhook management |
yes |
| Cancel | asynchronous (a few minutes) |
The core checks every envelope against this before sending, and the optional features go through the same calls as for every provider:
$esign->remind($ref, 'sara@example.sa'); // re-sends the link without duplicating the signer $esign->extendDeadline($ref, new DateTimeImmutable('+14 days')); $esign->details($ref)->pending(); // who hasn't signed yet $id = $esign->registerWebhook('https://app.example.sa/webhooks/sadq', provider: 'sadq'); $esign->webhooks('sadq'); $esign->deleteWebhook($id, 'sadq'); $esign->cancel($ref);
SADQ only: $esign->provider('sadq')->resendWebhook($envelopeId) asks SADQ to call your webhook again.
What this driver handles for you
Checked against the SADQ sandbox, October 2026:
- Errors come back as HTTP 200. A non-zero
errorCodealways throwsProviderError, carrying the HTTP status and SADQ's code, and never a credential. - The account owner is listed as a signer who already signed, at a varying position in the list. That entry is ignored, so a new envelope is
Sent, notPartiallySigned.Completedonly counts asSignedwhen every signer you invited has signed. - Cancelling is asynchronous.
status()keeps sayingSentfor a few minutes aftercancel()(under 5 minutes on the sandbox), thenCancelled. If your app needs to show "cancelled" right away, record it on your side. - Status by reference shows changes before status by ID. Every envelope gets a unique reference number, and status is read by reference.
/envelopes/{id}/filesreturns the unsigned original while an envelope is open, and the signed PDF once it is complete (a PAdES signature:adbe.pkcs7.detachedorETSI.CAdES.detached, both seen). Verified on completed sandbox envelopes.download()runs only when the status isSigned, and returns a file only if it contains a digital signature. If/filesstill has the original, it falls back to/documents/{id}/completed/base64, and otherwise throws.- An unknown envelope answers "Success" with no data. That becomes a
ProviderErrorwith HTTP 404. A single null for an existing envelope (also seen) is retried once first. - Sending invitations again duplicates the signer. Use
remind(). - One document per envelope. Adding files to an envelope fails on the sandbox. Merge the PDFs first.
- Passwords with
&,#or+are form-encoded correctly. The access token (about 27 hours on the sandbox) is cached and refreshed 5 minutes early, with one retry on a 401. - Identity:
emailandnafath(with a 10-digit national ID or Iqama). SMS OTP throws until SADQ's code for it is confirmed.
See getting started with SADQ for credentials, webhook setup and going to production.
Testing without real signers
Use the core's fake provider in your app's tests:
$esign = new Esign(['default' => 'fake']); $fake = $esign->provider(); $sent = $esign->send($envelope); $fake->sign($sent->envelopeId);
The package's own tests run against tests/Support/FakeSadq.php, a PSR-18 fake that reproduces every behaviour listed above. An opt-in suite runs against the real sandbox: vendor/bin/phpunit --group live.