Omnisign is one PHP contract for electronic signatures. An application asks every signature
service the same questions - create this envelope, send it, where does it stand, remind who is
late, withdraw it, give me the signed documents and their evidence, where does this signer sign in
my page, what is this webhook telling me - and gets the same answers back: an Envelope with its
reference and each signer's state, a Download, a SigningUrl, a Notification. Each service is a
package of its own: omnisign/yousign, omnisign/docusign, omnisign/docuseal (an instance of
one's own, or DocuSeal's cloud).
$gateway = $registry->get('contracts');
$envelope = $gateway->create(new Envelope('Bail', $documents, $signers, $fields, level: Level::SIMPLE)); // keep $envelope->reference
$envelope = $gateway->send($envelope); // the signers are asked
$url = $gateway->signingUrl($envelope, 'tenant', $back)->url; // or: sign in the application's page
$envelope = $gateway->fetch($envelope); // COMPLETED, each signer SIGNED
$download = $gateway->download($envelope); // the signed documents and the audit trail
$gateway->notify($request->getContent(), $request->headers->all()); // a webhook, its signature checked
The levels are eIDAS's (simple, advanced, qualified). A gateway has documents signed and keeps
nothing for the application: neither the documents nor their proof - the application stores what
download() gives it, in its own vault. It produces no PDF either: the documents are the
application's.
The design is Payum's, as in Omnibus and Omnibill: a gateway's factory fills a configuration
with its name, title, capabilities, options, its client (omnisign.api) and one action per
request it answers; the gateway runs the action that supports each request; a registry holds
the application's gateways by name. The core needs no framework: it requires
symfony/http-client-contracts and nothing else. A Symfony bundle is included (Symfony).
Published as omnisense/omnisign, formerly glitchr/omnisign and omniscient/omnisign: the package
replaces both names, so a composer.json that still requires one of them resolves to this package
and never installs two. The namespace is Omnisign\; the gateways keep the omnisign/ vendor and the
Omnisign\<Provider>\ namespace.
When to use it
- A site that has leases, quotes, mandates or sureties signed, and wants to change signature service (or move from a cloud service to an instance of its own) without rewriting the code that sends them.
- Signing in the application's own page (embedded), the provider sending the signers nothing.
- A qualified or advanced signature where the law asks one (Yousign).
It is not a signature service itself, and not an archive: it does not seal documents, keep evidence, or store anything.
Gateways
Three packages. The operations are what each gateway's supports() answers with its required
options set (the harness's bare lists them: harness); the capabilities are each
factory's capabilities().
| Package | Factory name | Levels | In the page | Third party | Create | Send | Fetch | Remind | Cancel | Download | Signing URL | Notify |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
omnisign/yousign |
yousign |
simple, advanced, qualified | yes | yes | yes (a draft) | yes | yes | yes | yes | yes | yes | with webhook_secret |
omnisign/docusign |
docusign |
simple | yes | yes | yes (a draft) | yes | yes | yes | yes | yes | yes (embedded only) | with hmac_keys |
omnisign/docuseal |
docuseal |
simple | yes | no for an instance of one's own, yes for DocuSeal's cloud | yes (sent at once) | yes (reads it back) | yes | yes | yes | yes | yes | with webhook_secret |
DocuSeal alone was run against a real service (an instance of its open-source edition, in Docker); Yousign and DocuSign were not (Verified, and not).
How this manual is organised
- Installation: requirements, a first envelope in plain PHP.
- Symfony and Laravel: wiring the registry in a framework.
- Concepts: gateways, requests and actions, factories, the registry, the HTTP helpers.
- Models: envelopes, documents, signers, fields, states, every field.
- Gateways: the contract and what each gateway does with it.
- Usage: envelopes, from creation to download, signing in the page, webhooks.
- Configuration reference: every option of every factory.
- Errors: the exceptions and when each is thrown.
- Testing: testing an application that uses Omnisign.
- The Docker harness: every gateway installed, the bare script.
- Writing a gateway.
- Verified, and not.
Propose a change docs/index.md
Installation
composer require omnisense/omnisign omnisign/docuseal # an instance of one's own: nothing leaves for a third party
composer require omnisign/yousign # simple, advanced, qualified
composer require omnisign/docusign
Requirements
- PHP 8.2 or later, with
mbstring(titles and error messages are cut withmb_substr()). omnisense/omnisignrequiressymfony/http-client-contracts(^3.0) and nothing else.- Each gateway requires
omnisense/omnisignandsymfony/http-client(6.4, 7 or 8), and takes the application's client when it is given one;omnisign/docusignalso requiresext-openssl(it signs its JWT with an RSA key). - For the Symfony bundle:
symfony/config,symfony/dependency-injection,symfony/http-kernel(6.4, 7 or 8), suggested, not required.
Omnisign runs the same in plain PHP, in a worker, in Laravel or Slim, and in Symfony, where a bundle does the wiring (Symfony).
Plain PHP
<?php // sign.php
require __DIR__.'/vendor/autoload.php';
use Omnisign\Docuseal\DocusealGatewayFactory;
use Omnisign\Model\Envelope;
use Omnisign\Model\Signer;
use Omnisign\Registry;
$registry = new Registry([new DocusealGatewayFactory()], [
'contracts' => ['factory' => 'docuseal', 'options' => ['url' => 'http://localhost:13300/api', 'api_key' => getenv('DOCUSEAL_API_KEY')]],
]);
$gateway = $registry->get('contracts');
printf("third party: %s\n", $gateway->capabilities()->thirdParty ? 'yes' : 'no');
// A lease from the instance's template 1, the tenant signing in the application's page.
$envelope = $gateway->create(new Envelope('Bail - harness', signers: [new Signer('tenant', 'Camille Érable', 'camille@erable.example')], embedded: true, template: '1', key: 'lease-42'));
printf("created: submission %s, %s; tenant %s\n", $envelope->reference, $envelope->status->value, $envelope->state('tenant')->status->value);
printf("signing url: %s\n", $gateway->signingUrl($envelope, 'tenant')->url);
// ... the tenant signs ...
$envelope = $gateway->fetch($envelope);
printf("fetched: %s; tenant %s at %s\n", $envelope->status->value, $envelope->state('tenant')->status->value, $envelope->state('tenant')->signedAt?->format(DATE_ATOM));
$download = $gateway->download($envelope);
foreach ($download->documents as $file) {
printf("signed: %s, %d bytes\n", $file->filename, strlen($file->content));
}
printf("evidence: %s, %d bytes\n", $download->evidence->filename, strlen($download->evidence->content));
$ php sign.php
third party: no
created: submission 2, sent; tenant waiting
signing url: http://localhost:13300/s/RKDjxDp8cpxaBA
fetched: completed; tenant signed at 2026-10-08T01:14:25+00:00
signed: lease.pdf, 17829 bytes
evidence: audit-log.pdf, 54542 bytes
(2026-10-08, against a DocuSeal instance of the open-source edition run in Docker; the tenant's part done through DocuSeal's API. Not run again for this manual: no instance was running on 2026-10-26; the same calls on the recorded answers are in Envelopes.)
That is all there is to it:
- a factory per gateway package, which takes the application's HTTP client when given one;
- the registry, built by hand from the factories and the gateways' options, by name;
- the gateways it gives, each answering the same questions (gateways).
No class of a framework is loaded on the way - a test of this package checks it in a process of
its own (Tests/BareTest.php), and so does docker compose run --rm omnisign bare
(harness).
What the application does around a gateway
- Decide who signs what, and at which level: a lease, a quote, a surety.
- Create the envelope and keep its reference with the application's own record; send it.
- Let each signer sign - the provider's e-mail, or
signingUrl()in the application's page (embedded: true), asked again each time. - Follow it:
fetch(), or the provider's webhook (notify());remind()who is late,cancel()what is withdrawn. - Once
COMPLETED, download the signed documents and their evidence, and store them: the provider is not the archive.
In a framework
- Symfony:
Omnisign\Bridge\Symfony\OmnisignBundleregisters the gateway factories on the application'shttp_client, builds the registry fromconfig/packages/omnisign.yamland makes each gateway injectable by its name: Symfony. - Laravel: there is no bridge package; a service provider builds the registry once: Laravel.
- Any other: build the
Registryonce, where the framework builds its services.
Errors
The whole list, and what each gateway maps to which: Errors.
| Exception | When |
|---|---|
NotSupportedException |
the gateway does not do that: supports() says so beforehand |
InvalidConfigException |
a gateway not configured, a factory not installed, an option missing; an envelope the provider cannot take (a level, a field, a template missing) |
InvalidKeyException |
the provider refused the application's credentials |
UnreachableException |
the provider did not answer |
InvalidNotificationException |
a callback whose signature does not hold, or too old |
ProviderException |
the provider refused the request (the three above extend it) |
All implement Omnisign\Exception\OmnisignException.
Propose a change docs/installation.md
Symfony
Omnisign runs without a framework (installation); in a Symfony application its
bundle does the wiring. Its components - symfony/config, symfony/dependency-injection,
symfony/http-kernel - are not required by omnisense/omnisign: the application has them, and
nothing of them is loaded outside Symfony. The bundle is part of the core
(Omnisign\Bridge\Symfony\OmnisignBundle); omnibridge has no Omnisign bridge, and none is needed.
Registering the bundle
Register Omnisign\Bridge\Symfony\OmnisignBundle (no Flex recipe):
// config/bundles.php
return [
// ...
Omnisign\Bridge\Symfony\OmnisignBundle::class => ['all' => true],
];
Configuration
# config/packages/omnisign.yaml
omnisign:
gateways: # by name: a factory and its options
contracts:
factory: docuseal
options: { url: '%env(DOCUSEAL_URL)%', api_key: '%env(DOCUSEAL_API_KEY)%', webhook_secret: '%env(default::DOCUSEAL_WEBHOOK_SECRET)%' }
qualified:
factory: yousign
options: { api_key: '%env(YOUSIGN_API_KEY)%', sandbox: '%env(bool:YOUSIGN_SANDBOX)%' }
The whole tree, from the bundle's configure():
| Key | Type | Default | |
|---|---|---|---|
omnisign.gateways |
map, keyed by the gateway's name | {} |
the application's gateways |
omnisign.gateways.<name>.factory |
string, required, not empty | - | a factory's name: yousign, docusign, docuseal, or an application's own factory's getName() |
omnisign.gateways.<name>.options |
any value (a map) | [] |
handed as they are to the factory's create() (Configuration) |
Processed with nothing set, the tree is {"gateways": []}; an empty factory is refused when the
container compiles (The path "omnisign.gateways.x.factory" cannot contain an empty value, but got "".). The options are checked when the gateway is built, the first time it is asked for.
Services
Every omnisign/* package installed registers its factory, autowired: the bundle knows
Omnisign\Yousign\YousignGatewayFactory, Omnisign\Docusign\DocusignGatewayFactory and
Omnisign\Docuseal\DocusealGatewayFactory, registers those whose class exists, and gives each the
application's http_client (null when it has none: the factory makes its own client).
| Service id | Class | Autowiring | |
|---|---|---|---|
Omnisign\Registry |
Registry |
Registry $registry |
every configured gateway by name: get(), has(), names(), options(), all(), create() with other options. Public. |
omnisign.gateway.<name> |
GatewayInterface |
GatewayInterface $<name> |
one gateway, built by Registry::get('<name>') the first time it is injected |
| the factories | their class | by their class | tagged omnisign.gateway_factory |
The argument's name selects the gateway: GatewayInterface $contracts is
omnisign.gateway.contracts (Symfony's named autowiring aliases).
Nothing is built when the container compiles: a gateway is built the first time it is asked for,
and an option left empty only shows then (InvalidConfigException). An application's own gateway
- a class implementing
GatewayFactoryInterface- is registered too, autoconfigured (taggedomnisign.gateway_factory), and itsgetName()can be named as afactory(Writing a gateway). The bundle registers no command, event, route or Twig function.
A provider's webhook
#[Route('/signatures/webhook', methods: ['POST'])]
public function __invoke(Request $request, GatewayInterface $contracts): Response
{
try {
$notification = $contracts->notify($request->getContent(), $request->headers->all());
} catch (InvalidNotificationException) {
return new Response('', 400);
}
$this->bus->dispatch(new FetchEnvelope($notification->reference)); // fetch, then download once completed
return new Response('', 200);
}
The route above is the application's: the bundle registers none. A callback sent twice has the same
Notification::$id: deduplicate on it (Webhooks).
Propose a change docs/symfony.md
Laravel
omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnisign provider, and there
is no other Laravel package for it. The registry is plain PHP: a service provider of the application
builds it once, from a config file, with a symfony/http-client client (every gateway requires it;
Laravel's own HTTP client is Guzzle's, which the gateways do not take).
// config/omnisign.php
return [
'gateways' => [
'contracts' => ['factory' => 'docuseal', 'options' => [
'url' => env('DOCUSEAL_URL'),
'api_key' => env('DOCUSEAL_API_KEY'),
'webhook_secret' => env('DOCUSEAL_WEBHOOK_SECRET'),
]],
'qualified' => ['factory' => 'yousign', 'options' => [
'api_key' => env('YOUSIGN_API_KEY'),
'sandbox' => env('YOUSIGN_SANDBOX', false),
]],
],
];
// app/Providers/OmnisignServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Omnisign\Docuseal\DocusealGatewayFactory;
use Omnisign\Registry;
use Omnisign\Yousign\YousignGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;
final class OmnisignServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Registry::class, function (): Registry {
$http = HttpClient::create();
return new Registry([
new DocusealGatewayFactory($http),
new YousignGatewayFactory($http),
], config('omnisign.gateways'));
});
}
}
Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the registry:
use Omnisign\Registry;
final class LeaseController
{
public function sign(Registry $registry, Lease $lease)
{
$gateway = $registry->get('contracts');
$envelope = $gateway->send($gateway->create($lease->envelope()));
// store $envelope->reference and $envelope->states with the lease
}
}
A webhook route reads the raw body and the headers - $gateway->notify($request->getContent(), $request->headers->all()) - and answers 400 on an InvalidNotificationException.
Differences from the Symfony bundle: list in the provider every factory the application uses, and
ask the registry for a gateway by name. An env() left unset is null: a required option that is
null is refused when the gateway is built; the boolean options (sandbox, third_party) also read
"1", "true", "yes", "on". This page was written from the registry's API; it was not run in a
Laravel application.
Propose a change docs/laravel.md
Concepts
Gateways
A gateway is one way to have documents signed, configured: Omnisign\GatewayInterface.
| Method | Request | Answers |
|---|---|---|
getName(): string |
- | the factory's name: yousign, docusign, docuseal |
getTitle(): string |
- | Yousign, DocuSign, DocuSeal |
capabilities(): Capabilities |
- | what its signatures are: levels, in the page, in turn, through whom |
supports(string $request): bool |
- | whether it answers that request class |
execute(Request $request): Request |
any | the same request, answered |
create(Envelope $envelope): Envelope |
Request\Create |
the envelope with its reference and each signer's state: keep them |
send(Envelope $envelope): Envelope |
Request\Send |
sent: the signers are asked (or may sign, embedded) |
fetch(Envelope $envelope): Envelope |
Request\Fetch |
as the provider has it now |
remind(Envelope $envelope, ?string $signer = null): Envelope |
Request\Remind |
the envelope; who has not signed is asked again (one signer by key) |
cancel(Envelope $envelope, ?string $reason = null): Envelope |
Request\Cancel |
CANCELED |
download(Envelope $envelope): Download |
Request\Download |
the signed documents and the evidence |
signingUrl(Envelope $envelope, string $signer, ?string $returnUrl = null): SigningUrl |
Request\SigningUrl |
where one signer signs in the application's page, now |
notify(string $body, array $headers = []): Notification |
Request\Notify |
a webhook's call, its signature checked |
Each typed method is execute(new Request\...) and the request's result. A request no action
supports is a NotSupportedException (The "x" gateway does not support Remind.); supports()
says so beforehand, by asking each action about an instance of the class built without its
constructor. Omnisign\Gateway is the one implementation: execute() hands the request to the first
action that supports it; an action that returns without a result is a ProviderException.
Requests
Omnisign\Request\Request is abstract: setResult(mixed), getResult(), isAnswered(). The
requests, with their public readonly properties and their typed getter:
| Request | Properties | Result |
|---|---|---|
Create, Send, Fetch |
envelope (Envelope) |
getEnvelope(): ?Envelope |
Remind |
envelope, signer (?string: a signer's key; null: all) |
getEnvelope(): ?Envelope |
Cancel |
envelope, reason (?string) |
getEnvelope(): ?Envelope |
Download |
envelope |
getDownload(): ?Model\Download |
SigningUrl |
envelope, signer (string, a key), returnUrl (?string) |
getSigningUrl(): ?Model\SigningUrl |
Notify |
body (string), headers (array<string, string|string[]>) |
getNotification(): ?Notification; header(string $name): ?string whatever the case it came in |
Actions
Omnisign\Action\ActionInterface: supports(Request $request): bool and execute(Request $request): void, which sets the request's result or throws an OmnisignException. An action that
works through the gateway's client implements Action\ApiAwareInterface (setApi(object $api));
Action\ApiAwareTrait holds it in $this->api and refuses a client that is not of the class it
names in $this->apiClass (InvalidConfigException).
Factories and their Config
Omnisign\GatewayFactoryInterface: getName(): string and create(array $options = []): GatewayInterface. The packages extend Omnisign\GatewayFactory, whose create():
- fills a
Configwith the options given and the factory'spopulateConfig()defaults under them; - checks every key of
omnisign.required_optionsis set and not empty (InvalidConfigException:The "yousign" gateway needs: api_key.); - builds the client:
omnisign.api, a closure of theConfigcalled now, or an object; - reads
omnisign.capabilities(aCapabilities, or a closure giving one; missing:InvalidConfigException); - collects every
omnisign.action.<name>: anActionInterface, or a closure of theConfiggiving one - or null, which leaves the action out (that is how each gateway dropsnotifywithout its webhook secret); eachApiAwareInterfaceaction is handed the client; - returns a
Gatewayofomnisign.factory_name,omnisign.factory_title, the capabilities and the actions.
Building calls nothing; a factory reached over HTTP takes its client in its own constructor.
Omnisign\Config is an ArrayObject of the options, with:
| Method | |
|---|---|
defaults(array $defaults): self |
sets each key not set yet |
validateNotEmpty(array $keys): void |
InvalidConfigException naming the keys unset, null, '' or [] |
get(string $key, mixed $default = null) |
|
bool(string $key): bool |
a boolean, or "1", "true", "yes", "on" (any case) as an environment variable gives it |
list(string $key): list<string> |
a list, each value split on commas and trimmed: 'a@x, b@x' is two |
The registry
Omnisign\Registry, built from the factories and the gateways' configuration by name:
| Method | |
|---|---|
get(string $name): GatewayInterface |
built once and kept. No "x" gateway; configured: contracts., No "x" factory for the "y" gateway; installed: yousign, docusign, docuseal. |
create(string $name, array $overrides = []): GatewayInterface |
built afresh, the configured options with $overrides over them (an instance typed in a back office); not kept |
has(string $name): bool |
whether the name is configured |
options(string $name): array |
its configured options |
names(): list<string>, all(): array<string, GatewayInterface> |
|
factories(): list<string> |
the factories installed: what factory may name |
HTTP helpers
Omnisign\Http\Answer::send(HttpClientInterface $http, string $provider, string $method, string $url, array $options = []): Answer- one request, read whole, with a 10-second timeout unless the options give one. No connection, a timeout, a transport error, an HTTP 5xx or a 429 is anUnreachableException(as far as can be told, nothing was done: fetch, then try again); any other answer comes back as anAnswer:status,headers(lower-case names),body,header($name),json()(the body as a JSON object, or aProviderException).Omnisign\Http\Multipart::build(array $fields, array $files): array{body, contentType}- amultipart/form-databody for a few fields and files (content,filename,type), its boundary random: no MIME library needed (Yousign's document upload).
The models these requests carry: Models.
Propose a change docs/concepts.md
Models
All models are readonly; times are DateTimeImmutable. They carry what a provider needs: files,
names, e-mails - never an account of the application's.
Envelope
| Property | |
|---|---|
title |
what the signers see |
documents |
list<Document>: a key of the application's, a File (content, filename, MIME type), signable (false: attached for reading) |
signers |
list<Signer> |
fields |
list<Field>: where each signer signs |
level |
Level::SIMPLE, ADVANCED, QUALIFIED (eIDAS) |
ordered |
each signer in turn (Signer::$order), rather than all at once |
embedded |
the signers sign in the application's page (signingUrl()); the provider sends them nothing |
expiresAt, message, redirectUrl |
a deadline, a message to the signers, where they go once signed |
template |
a template of the provider's, instead of documents and fields (DocuSeal's open-source edition) |
key |
the application's own name for it, kept by providers that keep one |
reference |
the provider's, once created: keep it. reference() throws on an envelope never created |
status |
Status::DRAFT, SENT, COMPLETED, DECLINED, EXPIRED, CANCELED, UNKNOWN; isOver() |
states |
array<string, SignerState> by signer key |
documentReferences, completedAt, data |
the provider's ids of the documents, when it completed, what else it answered |
Defaults: no documents, signers or fields; Level::SIMPLE; not ordered; not embedded; status
DRAFT; no states. Only title is required by the constructor: what each provider needs on top
(documents, signers, a template) it checks at create() (InvalidConfigException).
with(reference, status, states, documentReferences, completedAt, data) gives the envelope as the
provider has it now (each argument null keeps the envelope's); signer($key) the signer, or an
InvalidConfigException (No signer "x" in the envelope "Bail".); state($key) the signer's state
or null; fieldsOf($signer) the signer's fields; reference() the provider's reference, or an
InvalidConfigException (The envelope "Bail" was never created on a provider: create() it first.) - what every action after create() calls.
Status values are lower case (draft, sent, completed, declined, expired, canceled,
unknown); isOver() is true for COMPLETED, DECLINED, EXPIRED and CANCELED.
Document and File
new Document(string $key, File $file, bool $signable = true): the application's key for it (what
fields point at), the file, and signable (false: attached for reading, not signed - Yousign uploads
it as an attachment; DocuSign and DocuSeal send it as any document).
new File(string $content, string $filename, string $mimeType = 'application/pdf').
File::fromPath($path, ?$mimeType) reads a file (an \InvalidArgumentException when it cannot:
Cannot read <path>.), its MIME type application/pdf for a .pdf, application/octet-stream
otherwise; sha256().
Signer
key (the application's name for them: what fields and states point at), name, email,
phone, locale, order (from 1), authentication (none, email, sms: a code before
signing, where the provider sends one), role (the role a provider's template knows them by).
names() splits the name: the last word is the last name, the rest the first name - Camille Érable is Camille and Érable, Marie-Anne de La Tour is Marie-Anne de La and Tour (what
Yousign receives); a single word is both. Defaults: phone null, locale fr, order 1,
authentication null, role null. Which provider reads what: Gateways.
Field
signer, document (keys), type (FieldType::SIGNATURE, INITIALS, DATE, TEXT,
MENTION: "lu et approuvé"), page, x, y (from the page's top left corner, in the provider's
unit), width, height, anchor (a text of the document the provider looks for, where it can),
label (the mention's text, a text field's label). Defaults: SIGNATURE, page 1, at 0, 0. A
provider that cannot place a type refuses the envelope. FieldType values: signature,
initials, date, text, mention.
SignerState
key, reference (the provider's id), status (SignerStatus::WAITING, NOTIFIED, OPENED,
SIGNED, DECLINED, FAILED; values lower case; default WAITING), signedAt, link and linkExpiresAt (where they sign, when the
provider gave it), reason (why they declined).
Download, SigningUrl, Notification
Download:documents(list<File>: the signed documents, a ZIP when the provider packs several) andevidence(File: the audit trail, the certificate of completion).SigningUrl:url,expiresAt- made for one signer, now: ask again each time.Notification:event(Event::SENT,OPENED,SIGNED,DECLINED,COMPLETED,EXPIRED,CANCELED,OTHER),type(the provider's own),reference,signer,at,id(what a callback sent twice is recognised by),data.
Capabilities
levels, embedded (signing in the page), ordered, identityCheck (an identity document
checked: advanced, qualified), thirdParty, documents (envelopes from the application's PDFs),
templates (from a provider's templates). Defaults: simple only, not embedded, ordered, no
identity check, third party, documents yes, templates no. offers(Level), toArray() (the levels as
their values, and the other fields). Level values: simple, advanced, qualified.
Propose a change docs/models.md
Gateways
One contract, Omnisign\GatewayInterface:
| Method | Request | Answers |
|---|---|---|
create(Envelope) |
Request\Create |
the envelope with its reference: keep it |
send(Envelope) |
Request\Send |
SENT: the signers are asked |
fetch(Envelope) |
Request\Fetch |
its status, each signer's |
remind(Envelope, ?signer) |
Request\Remind |
who has not signed is asked again |
cancel(Envelope, ?reason) |
Request\Cancel |
CANCELED |
download(Envelope) |
Request\Download |
the signed documents and their evidence |
signingUrl(Envelope, signer, ?returnUrl) |
Request\SigningUrl |
where one signer signs in the page, now |
notify($body, $headers) |
Request\Notify |
a Notification, once the callback's signature holds |
omnisign/yousign |
omnisign/docusign |
omnisign/docuseal |
|
|---|---|---|---|
| Levels | simple, advanced, qualified | simple | simple |
create |
a draft: request, documents, signers and fields | an envelope, status created |
sent at once: from a template (open source) or from PDFs (Pro, cloud) |
send |
activate | status sent |
reads it back |
signingUrl |
the signer's signature_link |
a recipient view (clientUserId) |
the form /s/<slug> |
remind |
send_reminder per signer |
resend_envelope |
send_email per submitter |
cancel |
cancel (reason other) |
void | archive |
download |
the signed PDF (a ZIP when several), the audit trail | the combined PDF, the certificate of completion | the documents, the audit log |
notify |
X-Yousign-Signature-256 |
X-DocuSign-Signature-n (Connect, JSON) |
X-Docuseal-Signature |
| Needs | an API key | an integration, an RSA key, a user's consent, an account | an instance's URL and key |
Reads of a Signer |
name (split), email, phone, locale, order, authentication | name, email, order, key (as clientUserId, embedded); refuses an authentication other than none |
name, email, phone, key, order, authentication, role (templates) |
| Field types | signature, mention, text (others refused) | signature, initials, date, text, mention (as a text tab); positions or anchors | signature, initials, date, text, mention (as text); positions, no anchor |
Envelope::$message |
not sent | the e-mail's blurb | the e-mail's body (subject the title) |
redirectUrl |
each signer's success redirection | the signing view's return URL (when signingUrl() has none) |
the redirection once completed |
expiresAt |
the date (and its timezone) | a number of days from now | an expiry time in UTC |
The details, call by call: Envelopes, signing in the page, webhooks, and each gateway's own manual.
Signing in the page
$envelope = $gateway->send($gateway->create(new Envelope('Bail', $documents, $signers, $fields, embedded: true, redirectUrl: $back)));
$url = $gateway->signingUrl($envelope, 'tenant', $back)->url; // a frame, or a redirection - asked each time
The provider is a third party (but a DocuSeal of one's own): name it, and allow it in the page's
Content-Security-Policy (frame-src).
Writing a gateway
A package omnisign/<provider>: a factory extending Omnisign\GatewayFactory that fills a
Config - its name, title, capabilities, the options it needs, its client (omnisign.api) and one
action per request it answers - exactly as the three here do. Call the provider through
Omnisign\Http\Answer::send(), build an upload with Omnisign\Http\Multipart, and leave out what
the provider's documentation does not show. The whole guide: Writing a gateway.
Propose a change docs/gateways.md
Envelopes, from creation to download
The examples of these usage pages come from one script per gateway, run on 2026-10-26 on the
packages' checkouts (PHP 8.4, no network) with a MockHttpClient serving the packages' recorded
answers (docker/harness/config/recorded.php, the gateways' Tests/Fixtures); the > lines are the
mock's log of each request. The answers are fixtures: the ids and dates are theirs, and they answer
"completed" as soon as asked.
An envelope
use Omnisign\Model\Document;
use Omnisign\Model\Envelope;
use Omnisign\Model\Field;
use Omnisign\Model\FieldType;
use Omnisign\Model\File;
use Omnisign\Model\Level;
use Omnisign\Model\Signer;
$envelope = new Envelope(
'Bail - 12 rue des Juifs',
documents: [new Document('lease', new File("%PDF-1.7\n...", 'lease.pdf'))],
signers: [new Signer('landlord', 'Marie-Anne de La Tour', 'landlord@example.org', '+33612345678')],
fields: [
new Field('landlord', 'lease', FieldType::SIGNATURE, page: 2, x: 77, y: 581),
new Field('landlord', 'lease', FieldType::MENTION, page: 2, x: 77, y: 540, label: 'Lu et approuvé'),
],
level: Level::ADVANCED,
expiresAt: new DateTimeImmutable('2026-11-30', new DateTimeZone('Europe/Paris')),
redirectUrl: 'https://site.example/lease/42/signed',
key: 'lease-42',
);
Keys tie it together: a field names its signer and its document by key, and the provider's answers
come back as states by signer key.
Create, then send
$envelope = $gateway->create($envelope); // keep $envelope->reference, and $envelope->states
$envelope = $gateway->send($envelope);
On Yousign (api_key, sandbox: true):
create
> POST https://api-sandbox.yousign.app/v3/signature_requests {"name":"Bail - 12 rue des Juifs","delivery_mode":"email","ordered_signers":false,"expiration_date":"2026-11-30","timezone":"Europe\/Paris","external_id":"lease-42"}
> POST https://api-sandbox.yousign.app/v3/signature_requests/9a2b1c3d-0e4f-4a5b-8c6d-7e8f9a0b1c2d/documents
> POST https://api-sandbox.yousign.app/v3/signature_requests/9a2b1c3d-0e4f-4a5b-8c6d-7e8f9a0b1c2d/signers {"info":{"first_name":"Marie-Anne de La","last_name":"Tour","email":"landlord@example.org","phone_number":"+33612345678","locale":"fr"},"signature_level":"advanced_electronic_signature","fields":[{"document_id":"d0c1a2b3-c4d5-4e6f-8a7b-9c0d1e2f3a4b","page":2,"x":77,"y":581,"type":"signature"},{"document_id":"d0c1a2b3-c4d5-4e6f-8a7b-9c0d1e2f3a4b","page":2,"x":77,"y":540,"type":"mention","mention":"Lu et approuvé"}],"redirect_urls":{"success":"https:\/\/site.example\/lease\/42\/signed"}}
9a2b1c3d-0e4f-4a5b-8c6d-7e8f9a0b1c2d draft landlord=5e1a0b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c waiting
send
> POST https://api-sandbox.yousign.app/v3/signature_requests/9a2b1c3d-0e4f-4a5b-8c6d-7e8f9a0b1c2d/activate
sent landlord notified
| Gateway | create() |
send() |
|---|---|---|
yousign |
a draft, in one call for the signature request, one per document (multipart; nature signable_document or attachment) and one per signer with their fields. Status DRAFT |
POST .../activate: SENT; embedded, each signer's signature_link comes back |
docusign |
POST /envelopes, status created: the documents in base64 (documentId 1, 2...), the signers (recipientId 1, 2..., routingOrder their order when ordered, clientUserId their key when embedded) and their tabs. Status DRAFT |
PUT /envelopes/{id} status sent, then a GET ?include=recipients to read it back |
docuseal |
sent at once: from a template (POST /submissions, template_id) or from the documents (POST /submissions/pdf, DocuSeal Pro and its cloud); e-mails, or none when embedded. Status SENT |
reads it back (GET /submissions/{id}) |
What each one checks first (InvalidConfigException, nothing sent): Yousign and DocuSign want a
document and a signer; DocuSeal a signer, and a template or documents; DocuSign and DocuSeal refuse
a level other than simple; DocuSign refuses a signer authentication other than none, Yousign one
other than email, sms or none; Yousign places signature, mention and text fields only; a field that
points at no document is refused (Yousign, DocuSign); on a DocuSeal template each signer but the
first names the role they take.
Yousign asks a code by e-mail (otp_email) of every signer of a simple-level envelope that sets no
authentication.
Fetch
$envelope = $gateway->fetch($envelope);
$envelope->status; // Status::COMPLETED
$envelope->state('landlord')->status; // SignerStatus::SIGNED
fetch
> GET https://api-sandbox.yousign.app/v3/signature_requests/9a2b1c3d-0e4f-4a5b-8c6d-7e8f9a0b1c2d
completed landlord signed, completed 2026-10-09T17:30:00+00:00
Each provider's statuses as the family's:
| Family | Yousign | DocuSign | DocuSeal |
|---|---|---|---|
DRAFT |
draft |
created |
- |
SENT |
ongoing, approval, paused |
sent, delivered, signed |
pending |
COMPLETED |
done |
completed |
completed |
DECLINED |
declined, rejected |
declined |
declined |
EXPIRED |
expired |
voided with a reason containing "expired" |
expired |
CANCELED |
canceled, deleted |
voided |
archived (archived_at) |
UNKNOWN |
anything else | anything else | anything else |
| Signer | Yousign | DocuSign | DocuSeal |
|---|---|---|---|
WAITING |
initiated |
created |
awaiting |
NOTIFIED |
notified |
sent |
sent |
OPENED |
verified, processing, consent_given |
delivered |
opened |
SIGNED |
signed |
signed, completed |
completed |
DECLINED |
declined |
declined |
declined |
FAILED |
aborted, error |
autoresponded |
- |
A provider status not listed keeps the signer's previous state. A signer's signedAt is
DocuSign's signedDateTime and DocuSeal's completed_at; Yousign's provider sets none.
Remind, cancel
$gateway->remind($envelope); // every signer not signed yet
$gateway->remind($envelope, 'landlord'); // one, by key
$gateway->cancel($envelope, 'The lease was withdrawn.');
- Remind: Yousign
POST .../signers/{id}/send_reminderfor each signer whose state isNOTIFIED; DocuSign onePUT .../recipients?resend_envelope=truewith the signers neither signed nor declined (none: no call); DocuSealPUT /submitters/{id}withsend_email: truefor each signer neither signed nor declined. The envelope is answered as given:fetch()it. - Cancel: Yousign
POST .../cancel(reasonother, the text ascustom_note, 500 characters at most) and reads the answer; DocuSignPUT /envelopes/{id}statusvoidedwithvoidedReason(the text, 200 characters at most,Cancelled by the senderwithout one); DocuSealDELETE /submissions/{id}(archived). The envelope comes backCANCELED.
Download
$download = $gateway->download($envelope);
foreach ($download->documents as $file) {
$vault->store($envelope->reference, $file->filename, $file->content);
}
$vault->store($envelope->reference, $download->evidence->filename, $download->evidence->content);
download
> GET https://api-sandbox.yousign.app/v3/signature_requests/9a2b1c3d-0e4f-4a5b-8c6d-7e8f9a0b1c2d/documents/download?version=completed&archive=false
> GET https://api-sandbox.yousign.app/v3/signature_requests/9a2b1c3d-0e4f-4a5b-8c6d-7e8f9a0b1c2d/audit_trails/download?merge=true
lease.pdf application/pdf, evidence audit-trail.pdf application/pdf
| Gateway | Documents | Evidence |
|---|---|---|
yousign |
one file: the signed PDF named as the envelope's first document, or signed-documents.zip when there are several (archive=true); its type from the answer |
audit-trail.pdf (merge=true) |
docusign |
signed.pdf: the combined documents (certificate=false) |
certificate-of-completion.pdf |
docuseal |
each document's URL fetched, named after it (.pdf added) - the API key sent only to the API's own host |
audit-log.pdf (the audit_log_url), when given |
DocuSeal refuses a submission that is not completed (ProviderException: The submission is pending: its signed documents come once it is completed.); Yousign and DocuSign ask the provider
whatever the status, and pass its refusal on.
The gateway keeps none of it: store the documents and the evidence in the application's vault.
Propose a change docs/usage-envelopes.md
Signing in the page
With embedded: true, the provider sends the signers nothing: the application shows each one the
provider's signing page - in a frame, or by a redirection - from signingUrl().
$envelope = $gateway->send($gateway->create(new Envelope('Devis 2026-042', $documents, $signers, $fields, embedded: true, redirectUrl: $back)));
$url = $gateway->signingUrl($envelope, 'client', $back)->url; // made for this signer, now: ask again each time
| Gateway | What embedded changes at creation |
signingUrl() |
|---|---|---|
yousign |
delivery_mode: none |
the signer's signature_link from GET .../signers, with its expiry; none (not embedded, not activated) is a ProviderException |
docusign |
each signer's clientUserId is their key |
POST .../views/recipient (authenticationMethod: none, the signer's e-mail, name and key); the return URL is the call's, else the envelope's redirectUrl, else an InvalidConfigException. An envelope not created embedded is refused |
docuseal |
send_email: false |
the submitter's embed_src, or <app_url>/s/<slug>, from the creation's answer, else GET /submitters/{id} |
returnUrl is read by DocuSign only; Yousign and DocuSeal send the signer to the envelope's
redirectUrl. A signer not created with the envelope (no state) is an InvalidConfigException.
On DocuSeal and DocuSign, on the recorded answers:
docusign
> POST https://account-d.docusign.com/oauth/token
> POST https://demo.docusign.net/restapi/v2.1/accounts/0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d/envelopes
7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f draft client=1 waiting
docuseal
> POST http://localhost:13300/api/submissions
4 sent tenant=4 waiting http://localhost:13300/s/KKjaQ1uucspGL5
The DocuSeal link came with the creation's answer: signingUrl() called nothing.
In the page
The provider is a third party (but a DocuSeal instance of one's own: capabilities()->thirdParty
is false there): name it to the signer, and allow its host in the page's Content-Security-Policy
(frame-src). DocuSeal's form can also be its docuseal-form web component, whose src is this
URL. A DocuSign view is to be used at once (the action's own note): ask it right before showing it.
Propose a change docs/usage-embedded.md
Webhooks
Each provider can call the application when an envelope moves. With the gateway's webhook secret
set, notify() checks the call's signature and reads it as one Notification:
$notification = $gateway->notify($request->getContent(), $request->headers->all());
$notification->event; // Event::SIGNED, COMPLETED...
$notification->type; // the provider's own name for it: "signer.done"
$notification->reference; // the envelope, as create() named it
$notification->signer; // the provider's id of the signer (DocuSeal: their key)
$notification->id; // the same for a callback sent twice: deduplicate on it
Without the secret, notify() is not supported (NotSupportedException). A call that does not hold
is an InvalidNotificationException: answer 400. Answer fast, and fetch() the envelope out of the
request - then download() once it is completed.
The signatures
| Gateway | Option | Header | Checked |
|---|---|---|---|
yousign |
webhook_secret |
X-Yousign-Signature-256 |
sha256= and the hexadecimal HMAC-SHA-256 of the raw body under the secret |
docusign |
hmac_keys (one or several, comma-separated) |
X-DocuSign-Signature-1, -2... (up to 100) |
any of them equal to the base64 HMAC-SHA-256 of the raw body under any key; the message must be JSON (configure Connect to send JSON) |
docuseal |
webhook_secret (and webhook_tolerance, 300 s) |
X-Docuseal-Signature |
<timestamp>.<hexadecimal HMAC-SHA-256 of "<timestamp>.<body>">, the timestamp within the tolerance (0: any age) |
Run with signatures computed as each provider does, then a forged one (Yousign):
notify
signed signer.done 9a2b1c3d-0e4f-4a5b-8c6d-7e8f9a0b1c2d 5e1a0b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c 2026-10-26T09:00:00+00:00
[yousign] The signature does not hold.
notify: signed recipient-completed 7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f 1 7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f@recipient-completed@1@2026-10-26T09:00:00Z
notify: signed form.completed 4 tenant form.completed@7@2026-10-26T09:00:00Z
(the second line is DocuSign's, the third DocuSeal's.)
The events
| Event | Yousign event_name |
DocuSign event |
DocuSeal event_type |
|---|---|---|---|
SENT |
signature_request.activated |
envelope-sent |
submission.created |
OPENED |
signer.link_opened |
envelope-delivered, recipient-delivered |
form.viewed, form.started |
SIGNED |
signer.done |
recipient-completed |
form.completed |
DECLINED |
signature_request.declined, signature_request.rejected, signer.declined |
envelope-declined, recipient-declined |
form.declined |
COMPLETED |
signature_request.done |
envelope-completed |
submission.completed |
EXPIRED |
signature_request.expired |
- | submission.expired |
CANCELED |
signature_request.canceled |
envelope-voided |
submission.archived |
OTHER |
anything else | anything else | anything else |
| Field | Yousign | DocuSign | DocuSeal |
|---|---|---|---|
reference |
data.signature_request.id |
data.envelopeId |
a form event: data.submission_id; else data.id |
signer |
data.signer.id |
data.recipientId |
a form event: data.external_id (the signer's key), else data.id |
at |
event_time (epoch seconds) |
generatedDateTime |
timestamp |
id |
event_id |
<envelopeId>@<event>@<recipientId>@<generatedDateTime> |
<event_type>@<data.id>@<timestamp> |
Propose a change docs/usage-webhooks.md
Configuration reference
The registry
new Registry(iterable $factories, array $config);
| Key | Type | Required | |
|---|---|---|---|
<name> |
string | - | the gateway's name: get('<name>'), and in Symfony the argument's name |
<name>.factory |
string | yes | a factory's getName(): yousign, docusign, docuseal, or an application's own |
<name>.options |
map | no, default [] |
handed to the factory's create(); create($name, $overrides) puts other values over them |
The same tree is the Symfony bundle's omnisign.gateways (Symfony). A
required option that is unset, null, '' or [] is refused when the gateway is built
(InvalidConfigException: The "yousign" gateway needs: api_key.). Booleans are read with
Config::bool() (true, or "1", "true", "yes", "on"), lists with Config::list() (a list,
or a comma-separated string).
Yousign (yousign)
new YousignGatewayFactory(?HttpClientInterface $http = null).
| Option | Type | Default | Required | |
|---|---|---|---|---|
api_key |
string | - | yes | sent as Authorization: Bearer |
sandbox |
bool | false |
no | https://api-sandbox.yousign.app/v3: signatures without legal value |
webhook_secret |
string | none | no | the webhook subscription's secret; without it, notify() is not supported |
url |
string | https://api.yousign.app/v3 (or the sandbox's) |
no | another API root, over sandbox |
Capabilities: simple, advanced, qualified; in the page; ordered; identity check; third party; documents; no templates.
DocuSign (docusign)
new DocusignGatewayFactory(?HttpClientInterface $http = null).
| Option | Type | Default | Required | |
|---|---|---|---|---|
integration_key |
string | - | yes | the integration's client id (the JWT's iss) |
user_id |
string | - | yes | the user it acts as, who consented once (the JWT's sub) |
private_key |
string | - | yes | the RSA key's PEM, or the path of a file holding it |
account_id |
string | - | yes | the API account id (a GUID) |
sandbox |
bool | true |
no | account-d.docusign.com and https://demo.docusign.net/restapi; false: account.docusign.com and the account's base URI from the userinfo |
base_uri |
string | none | no | the account's REST base (https://<host>/restapi), over the above |
hmac_keys |
list or string | [] |
no | Connect's HMAC keys; without one, notify() is not supported |
Note the default: sandbox is true - set it false for production.
Capabilities: simple; in the page; ordered; no identity check; third party; documents; no templates.
DocuSeal (docuseal)
new DocusealGatewayFactory(?HttpClientInterface $http = null).
| Option | Type | Default | Required | |
|---|---|---|---|---|
url |
string | - | yes | the API's root: https://<instance>/api, or DocuSeal's cloud https://api.docuseal.com, https://api.docuseal.eu |
api_key |
string | - | yes | the instance's API key (Settings, API), sent as X-Auth-Token |
app_url |
string | the url without /api; for the cloud https://docuseal.com or https://docuseal.eu |
no | where signers' forms open: <app_url>/s/<slug> |
webhook_secret |
string | none | no | notify() checks X-Docuseal-Signature with it; without it, not supported |
webhook_tolerance |
int | 300 |
no | seconds a callback may be old; 0: any age |
third_party |
bool | told from the url: yes for a host under docuseal.com, .eu or .co, no otherwise |
no | whether the instance is someone else's (capabilities()->thirdParty) |
Capabilities: simple; in the page; ordered; no identity check; documents; templates.
Environment variables
The packages read no environment variable themselves. The names in these pages and in the harness
are conventions: YOUSIGN_API_KEY, YOUSIGN_SANDBOX, YOUSIGN_WEBHOOK_SECRET;
DOCUSIGN_INTEGRATION_KEY, DOCUSIGN_USER_ID, DOCUSIGN_PRIVATE_KEY, DOCUSIGN_ACCOUNT_ID,
DOCUSIGN_HMAC_KEYS; DOCUSEAL_URL, DOCUSEAL_API_KEY, DOCUSEAL_WEBHOOK_SECRET; the harness's
OMNISIGN_PLUGINS and OMNISIGN_AUTOLOAD.
Propose a change docs/configuration.md
Errors
Every exception implements Omnisign\Exception\OmnisignException (an interface that extends
\Throwable), except the \InvalidArgumentException of File::fromPath() for a file it cannot read.
| Exception | Parent | Thrown when |
|---|---|---|
InvalidConfigException |
\LogicException |
a gateway not configured, a factory not installed, a required option missing; a DocuSign key it cannot sign with; an envelope the provider cannot take (a level, a field type, a signer's authentication, no document, no signer, no role on a template); an envelope never created; a signer not in the envelope |
NotSupportedException |
\LogicException |
the gateway does not do that request (notify() without its secret): supports() says so beforehand |
ProviderException |
\RuntimeException |
the provider refused the request or answered with an error; an action left a request unanswered; DocuSeal's download of a submission not completed; no signature link on Yousign |
InvalidKeyException |
ProviderException |
the provider refused the credentials: HTTP 401 or 403; DocuSign's JWT grant refused (consent_required when the user never consented) |
UnreachableException |
ProviderException |
the provider did not answer: no connection, a timeout, a 5xx, a 429. As far as can be told nothing was done: fetch, then try again |
InvalidNotificationException |
ProviderException |
a webhook's call that does not hold: no secret configured, a header missing or malformed, too old, a wrong signature, not JSON |
ProviderException and its children carry provider (yousign, docusign, docuseal) and
providerCode (?string: DocuSign's errorCode, the HTTP status, the JWT grant's error, or
null); the message is [<provider>] <message>, e.g. [yousign] POST /signature_requests: HTTP 400, <detail> (<param>: <reason>), [docusign] GET /v2.1/accounts/<account>/envelopes/<id>: HTTP 404, <errorCode> <message>, [docuseal] The API key was refused (HTTP 401): ....
By gateway
yousign |
docusign |
docuseal |
|
|---|---|---|---|
| 401, 403 | InvalidKeyException |
InvalidKeyException |
InvalidKeyException |
| other 4xx | ProviderException (detail, title or type, and the invalid_params) |
ProviderException (errorCode and message, providerCode the errorCode) |
ProviderException (error or message) |
| the token | - | the JWT grant answering 400 or more, or without access_token: InvalidKeyException; the account id not among the user's accounts: InvalidConfigException |
- |
| 5xx, 429, no answer | UnreachableException |
UnreachableException |
UnreachableException |
| a body that is not JSON | ProviderException |
ProviderException |
ProviderException |
Error messages carry at most 200 characters of a body that is not the provider's JSON error.
What to catch
use Omnisign\Exception\InvalidConfigException;
use Omnisign\Exception\InvalidKeyException;
use Omnisign\Exception\ProviderException;
use Omnisign\Exception\UnreachableException;
try {
$envelope = $gateway->send($gateway->create($envelope));
} catch (UnreachableException) {
// maybe nothing was done: fetch() if the envelope has a reference, then retry
} catch (InvalidKeyException) {
// the credentials are refused: an administrator's problem
} catch (ProviderException $e) {
// the provider refused this envelope: $e->providerCode, $e->getMessage()
} catch (InvalidConfigException $e) {
// the envelope lacks what this gateway needs, or a configuration error
}
A signer who declines, or an envelope that expires, is not an exception: it is a status
(DECLINED, EXPIRED) of a later fetch() or a webhook.
No secret in a failure
Answer::send() keeps the request's secrets out of what it throws. The HTTP client's message
quotes the URL called: its key parameters, a password in its user part and every secret of the
request's options are masked (api_key=***, Http\Redaction::text()). The previous exception is
a copy of the client's (Redaction::copy(): the same class and code, that message, its own
previous copied the same way), and no trace of the chain keeps its arguments, where the query,
headers and body stood: a log that writes the whole chain, arguments included
(zend.exception_ignore_args off), holds no key.
Propose a change docs/errors.md
Testing
An application that uses Omnisign tests its own code, not the providers: every gateway takes the
HTTP client it calls with, so a test hands it a MockHttpClient and the answers it wants, and checks
what was sent.
Omnisign ships no fake gateway and no conformance suite: there is no Testing\ namespace in the
package.
A test case
use Omnisign\Docuseal\DocusealGatewayFactory;
use Omnisign\Exception\InvalidConfigException;
use Omnisign\Model\Envelope;
use Omnisign\Model\Level;
use Omnisign\Model\Signer;
use Omnisign\Model\SignerStatus;
use Omnisign\Model\Status;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class SigningTest extends TestCase
{
public function testALeaseIsSentFromTheInstancesTemplate(): void
{
$created = new MockResponse('[{"id":11,"submission_id":4,"external_id":"tenant","status":"awaiting","slug":"abc"}]');
$gateway = (new DocusealGatewayFactory(new MockHttpClient([$created])))->create(['url' => 'https://sign.example.org/api', 'api_key' => 'test']);
$envelope = $gateway->create(new Envelope('Bail', signers: [new Signer('tenant', 'Camille Érable', 'camille@erable.example')], embedded: true, template: '1'));
self::assertSame('4', $envelope->reference);
self::assertSame(Status::SENT, $envelope->status);
self::assertSame(SignerStatus::WAITING, $envelope->state('tenant')->status);
self::assertSame('https://sign.example.org/s/abc', $gateway->signingUrl($envelope, 'tenant')->url);
$sent = json_decode($created->getRequestOptions()['body'], true);
self::assertSame(['template_id' => 1, 'submitters' => [['role' => 'First Party', 'email' => 'camille@erable.example', 'name' => 'Camille Érable', 'external_id' => 'tenant', 'send_email' => false]], 'send_email' => false, 'order' => 'random'], $sent);
}
public function testAQualifiedSignatureIsRefusedBeforeAnyCall(): void
{
$http = new MockHttpClient([]);
$gateway = (new DocusealGatewayFactory($http))->create(['url' => 'https://sign.example.org/api', 'api_key' => 'test']);
$this->expectException(InvalidConfigException::class);
try {
$gateway->create(new Envelope('Caution', signers: [new Signer('surety', 'A B', 'a@b.example')], level: Level::QUALIFIED, template: '2'));
} finally {
self::assertSame(0, $http->getRequestsCount());
}
}
}
$ vendor/bin/phpunit --bootstrap vendor/autoload.php SigningTest.php
PHPUnit 11.5.57 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.27
.. 2 / 2 (100%)
Time: 00:00.026, Memory: 8.00 MB
OK (2 tests, 7 assertions)
(run on 2026-10-26 on the packages' checkouts, no network)
MockResponse::getRequestOptions()['body']is the JSON sent: what the provider receives of the envelope.- A
MockHttpClientbuilt with an empty list fails on any request:getRequestsCount()proves a refused envelope sent nothing. - DocuSign asks its token first (a JWT signed with the RSA key): the first answer is the token
endpoint's, and a test needs a real RSA key -
openssl_pkey_new()makes one. - A webhook is tested by signing the body as the provider does (Webhooks):
$body = json_encode(['event_name' => 'signer.done', 'event_id' => 'e-1', 'data' => ['signature_request' => ['id' => 'r-1'], 'signer' => ['id' => 's-1']]]);
$headers = ['X-Yousign-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'the-webhook-secret')];
$notification = $yousign->notify($body, $headers);
Recorded answers
The gateways' own tests keep answers written from the providers' documented shapes, in each
package's Tests/Fixtures; the core's docker/harness/config/recorded.php returns a
MockHttpClient callback that serves each call from them by URL - the examples of the
usage pages run on it:
$http = new MockHttpClient(require 'vendor/omnisense/omnisign/docker/harness/config/recorded.php');
| Package | Fixtures |
|---|---|
omnisign/yousign |
activate.json, canceled.json, document.json, error-400.json, signature-request-done.json, signature-request-draft.json, signer-landlord.json, signer-tenant.json, signers.json, webhook-signer-done.json |
omnisign/docusign |
connect-recipient-completed.json, consent-required.json, envelope-completed.json, envelope-created.json, envelope-sent.json, envelope-update.json, error-400.json, recipients-update.json, token.json, userinfo.json, view.json |
omnisign/docuseal |
submission-archived.json, submission-completed.json, submission-pdf-404.json, submission-pending.json, submissions-create.json, unauthorized.json |
In Symfony
The bundle builds the factories on http_client: replace it with a MockHttpClient in the test
environment (framework.http_client.mock_response_factory), or a gateway's service
(omnisign.gateway.<name>) with static::getContainer()->set(...) before it is first used.
The package's own tests
docker compose run --rm omnisign test runs every package's tests in the harness: the
core's (the gateway and its requests, the factory and its config, the registry, the models, the HTTP
helpers, the bundle, the plain-PHP run with no framework class loaded) and each gateway's on its
fixtures.
Propose a change docs/testing.md
The Docker harness
docker/ runs this package with every omnisign/* gateway installed - from GitHub (branch 1.x),
or from the checkouts beside this one when OMNIBILL_PLUGINS=../.. is set in docker/.env.
cd docker && cp .env.dist .env # a provider's keys, a DocuSeal instance, when you have them
docker compose run --rm omnisign bare
| Command | |
|---|---|
gateways |
the gateways installed and configured, what each offers and does - nothing called |
bare |
plain PHP: the registry built by hand, a lease signed through each gateway, what PHP loaded |
bare --live |
the same, DocuSeal for real (DOCUSEAL_URL, DOCUSEAL_API_KEY, DOCUSEAL_TEMPLATE) |
bare --json |
the same, whole: every class and file loaded |
test |
every package's tests |
Bare: no bundle, no container
docker/harness/bin/bare requires the autoloader and nothing else. It builds the Registry from
the gateway packages installed and has a lease signed through each - created, sent, fetched,
downloaded - the providers answering from their packages' recorded answers, then lists what PHP
loaded, and exits 1 if a class of a framework is among it:
$ docker compose run --rm omnisign bare
Omnisign in bare PHP: the registry built by hand, no bundle, no container; the providers' recorded answers.
yousign Yousign: levels simple, advanced, qualified; in the page yes; third party yes; does create send fetch remind cancel download signing-url
docusign DocuSign: levels simple; in the page yes; third party yes; does create send fetch remind cancel download signing-url
docuseal DocuSeal: levels simple; in the page yes; third party no; does create send fetch remind cancel download signing-url
yousign created 9a2b1c3d-0e4f-4a5b-8c6d-7e8f9a0b1c2d (draft), sent: sent, fetched: completed; downloaded 1 document(s) (%PDF-1.7), evidence yes
docusign created 7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f (draft), sent: completed, fetched: completed; downloaded 1 document(s) (%PDF-1.7), evidence yes
docuseal created 4 (sent), sent: completed, fetched: completed; downloaded 1 document(s) (%PDF-1.4), evidence yes
Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle or bridge, Doctrine, Twig): none
(2026-10-08; the recordings answer "completed" as soon as asked) Tests/BareTest.php runs
bare --json in a process of its own and checks the list.
The image is php:8.4-cli-alpine with Composer; the harness's packages live in the harness
volume of the omnisign-harness project.
Run on 2026-10-26
bare with no option, run on the packages' checkouts through
OMNISIGN_AUTOLOAD=vendor/autoload.php php core/docker/harness/bin/bare (PHP 8.4, no network),
printed the lines above unchanged.
The entrypoint
docker/entrypoint.sh runs docker/harness/setup.php first: it writes the harness's
composer.json and phpunit.xml in the harness volume - this core from its checkout, each
gateway from its checkout under OMNISIGN_PLUGINS when one is there, from
https://github.com/glitchr-omnisense/omnisign-<slug>.git (branch 1.x) otherwise. Then
composer install the first time, composer update when composer.json is newer than the lock,
and the command: test (PHPUnit), bare, composer, sh or php as they are, anything else the
console (gateways by default).
docker/harness/config/gateways.php says which environment variables configure each gateway
(YOUSIGN_API_KEY; DOCUSIGN_INTEGRATION_KEY, DOCUSIGN_USER_ID, DOCUSIGN_PRIVATE_KEY,
DOCUSIGN_ACCOUNT_ID; DOCUSEAL_URL, DOCUSEAL_API_KEY) and the example options bare uses
instead (DocuSign's private key generated for the run).
Propose a change docs/harness.md
Writing a gateway
A gateway is a factory that extends Omnisign\GatewayFactory and one action per request it
answers. It can live in the application or be a package of its own, as omnisign/yousign,
omnisign/docusign and omnisign/docuseal are.
The rules
- One action per request, only those the provider documents. A request no action supports is a
NotSupportedException, andsupports()says so beforehand; a factory may leave an action out when an option is missing (a closure of theConfigthat returns null - asnotifywithout a webhook secret). - Every action answers.
execute()sets the request's result, or throws anOmnisignException; returning without a result is aProviderExceptionof the gateway. - Refuse before sending. What the provider cannot take - a level, a field type, a signer's
authentication, an envelope without document or signer - is an
InvalidConfigExceptionbefore any call. - Give back what to keep.
create()answers the envelopewith()itsreferenceand aSignerStateper signer key, holding the provider's id of each signer: the later calls find them by it. Map the provider's statuses toStatusandSignerStatus,UNKNOWNfor the rest. - Through the helpers. Call the provider with
Omnisign\Http\Answer::send(): no answer, a 5xx or a 429 is then anUnreachableException; build an upload withOmnisign\Http\Multipart. - Errors. Refused credentials (401, 403) are an
InvalidKeyException; any other refusal aProviderExceptionwith the provider's code; a callback that does not hold anInvalidNotificationException. - Keep nothing. The documents and the evidence go back to the application in a
Download.
A gateway of the application's
A sketch of a service with an API key and two routes:
<?php // signing.php
require __DIR__.'/vendor/autoload.php';
use Omnisign\Action\ActionInterface;
use Omnisign\Action\ApiAwareInterface;
use Omnisign\Action\ApiAwareTrait;
use Omnisign\Config;
use Omnisign\Exception\InvalidConfigException;
use Omnisign\Exception\ProviderException;
use Omnisign\GatewayFactory;
use Omnisign\Http\Answer;
use Omnisign\Model\Capabilities;
use Omnisign\Model\Document;
use Omnisign\Model\Envelope;
use Omnisign\Model\File;
use Omnisign\Model\Level;
use Omnisign\Model\Signer;
use Omnisign\Model\Status;
use Omnisign\Request\Create;
use Omnisign\Request\Fetch;
use Omnisign\Request\Remind;
use Omnisign\Request\Request;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/** A signature service's API: a key, two routes. */
final class AcmeSignApi
{
public function __construct(private readonly HttpClientInterface $http, private readonly string $url, #[\SensitiveParameter] private readonly string $key)
{
}
public function call(string $method, string $path, array $json = []): array
{
$answer = Answer::send($this->http, 'acme', $method, $this->url.$path, ['headers' => ['Authorization' => 'Bearer '.$this->key]] + ($json ? ['json' => $json] : []));
if ($answer->status >= 400) {
throw new ProviderException('acme', sprintf('%s %s: HTTP %d', $method, $path, $answer->status), (string) $answer->status);
}
return $answer->json();
}
}
abstract class AcmeAction implements ActionInterface, ApiAwareInterface
{
/** @use ApiAwareTrait<AcmeSignApi> */
use ApiAwareTrait;
public function __construct()
{
$this->apiClass = AcmeSignApi::class;
}
protected static function status(string $status): Status
{
return ['open' => Status::SENT, 'signed' => Status::COMPLETED][$status] ?? Status::UNKNOWN;
}
}
final class AcmeCreateAction extends AcmeAction
{
public function supports(Request $request): bool
{
return $request instanceof Create;
}
public function execute(Request $request): void
{
\assert($request instanceof Create);
$envelope = $request->envelope;
if (Level::SIMPLE !== $envelope->level || !$envelope->documents || !$envelope->signers) {
throw new InvalidConfigException('The "acme" gateway signs one simple envelope with a document and a signer at least.');
}
$made = $this->api->call('POST', '/requests', [
'title' => $envelope->title,
'file' => base64_encode($envelope->documents[0]->file->content),
'signers' => array_map(static fn (Signer $s) => ['name' => $s->name, 'email' => $s->email], $envelope->signers),
]);
$request->setResult($envelope->with(reference: (string) $made['id'], status: self::status((string) $made['status'])));
}
}
final class AcmeFetchAction extends AcmeAction
{
public function supports(Request $request): bool
{
return $request instanceof Fetch;
}
public function execute(Request $request): void
{
\assert($request instanceof Fetch);
$data = $this->api->call('GET', '/requests/'.$request->envelope->reference());
$request->setResult($request->envelope->with(status: self::status((string) $data['status']), data: $data));
}
}
final class AcmeGatewayFactory extends GatewayFactory
{
public function __construct(private readonly ?HttpClientInterface $http = null)
{
}
protected function populateConfig(Config $config): void
{
$config->defaults([
'omnisign.factory_name' => 'acme',
'omnisign.factory_title' => 'Acme Sign',
'omnisign.required_options' => ['api_key'],
'url' => 'https://api.acme-sign.example/v1',
'omnisign.capabilities' => new Capabilities([Level::SIMPLE], embedded: false, ordered: false),
'omnisign.api' => fn (Config $c) => new AcmeSignApi($this->http ?? HttpClient::create(), rtrim((string) $c['url'], '/'), (string) $c['api_key']),
'omnisign.action.create' => new AcmeCreateAction(),
'omnisign.action.fetch' => new AcmeFetchAction(),
]);
}
}
$http = new MockHttpClient([new MockResponse('{"id":"r-7","status":"open"}', ['http_code' => 201]), new MockResponse('{"id":"r-7","status":"signed"}')]);
$gateway = (new AcmeGatewayFactory($http))->create(['api_key' => 'test']);
$envelope = $gateway->create(new Envelope('Devis', [new Document('quote', new File('%PDF-1.7', 'quote.pdf'))], [new Signer('client', 'Camille Érable', 'camille@erable.example')]));
echo $gateway->getTitle(), ': ', $envelope->reference, ' ', $envelope->status->value, "\n";
echo $gateway->fetch($envelope)->status->value, "\n";
var_dump($gateway->supports(Remind::class));
try {
$gateway->fetch(new Envelope('Never created'));
} catch (InvalidConfigException $e) {
echo $e->getMessage(), "\n";
}
$ php signing.php
Acme Sign: r-7 sent
completed
bool(false)
The envelope "Never created" was never created on a provider: create() it first.
(run on 2026-10-26 on a MockHttpClient, PHP 8.4; the service is made up, nothing was called.)
Envelope::reference() refuses an envelope never created before any call.
In Symfony, the factory is autoconfigured (tagged omnisign.gateway_factory) as soon as it is a
service, and named in omnisign.gateways.<name>.factory.
The factory's Config
populateConfig(Config $config) sets, with $config->defaults([...]):
| Key | |
|---|---|
omnisign.factory_name |
the factory's name, what factory: names |
omnisign.factory_title |
a readable name, getTitle() |
omnisign.required_options |
the options create() refuses to go without |
omnisign.capabilities |
a Capabilities (or a closure of the Config giving one): required |
omnisign.api |
the client the actions work with, or a closure of the Config building it at create() |
omnisign.action.<name> |
an action, or a closure of the Config giving one or null |
| every other option | its default |
A package of its own
The shipped gateways' layout, to copy: name omnisign/<provider>, namespace
Omnisign\<Provider>\, require php >=8.2, omnisense/omnisign ^1.0@dev,
symfony/http-client and what the provider needs (ext-openssl for a JWT); an Api class and an
Action/ directory with an AbstractAction holding the ApiAwareTrait and the status maps; tests
on answers written from the provider's documented shapes, in Tests/Fixtures; a README with a
"Responsibility" section. For the bundle to register the factory without the application declaring
it, its class must be in OmnisignBundle::FACTORIES; for the harness, in
docker/harness/plugins.php, config/gateways.php and config/recorded.php.
Propose a change docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-26 on the four packages' checkouts (the workspace's autoloader), PHP 8.4.27, PHPUnit 11.5.57, no network: 27 tests, 150 assertions, green.
- The core (10 tests): a signer's names and an envelope's parts; statuses, files, capabilities and a
multipart body; an envelope created, sent and fetched through a gateway; what a gateway does not do
said beforehand and thrown; misconfiguration and an envelope never created; the Symfony bundle
(gateways built and injectable by name, the packages installed registered with the application's
client); the plain-PHP run with no framework class loaded and a lease through each gateway
(
Tests/BareTest.php). omnisign/yousign(6 tests),omnisign/docusign(5) andomnisign/docuseal(6): each operation on its fixtures, the webhooks' signatures, the errors mapped.
The examples of these pages were run on 2026-10-26 on the same checkouts, the providers answering
from the packages' fixtures through the harness's recorded.php.
What was run live, and what was not
- DocuSeal: run for real on 2026-10-08 against an instance of the open-source edition in Docker
- created from a template, the signing link, completed, downloaded, archived - its answers recorded
as the tests' fixtures; the installation script's output is that run's.
/submissions/pdf(DocuSeal Pro and the cloud) is refused by an open-source instance and was not run.
- created from a template, the signing link, completed, downloaded, archived - its answers recorded
as the tests' fixtures; the installation script's output is that run's.
- Yousign and DocuSign: not run against the services; their fixtures are written from the providers' documented shapes.
- The Laravel page was written from the registry's API; it was not run in a Laravel application.
- The known defect found while writing these pages, fixed since:
ext-mbstringnot required thoughmb_substr()is called (composer.json requires it).
Responsibility
omnisense/omnisign and its omnisign/* gateways are independent, unofficial clients: none is
affiliated with, endorsed by or sponsored by the services it reaches. Each keeps its own terms
(accounts, keys, the legal value of each level, what may be signed), named with a link in each
gateway's README. The MIT licence covers this code only; whoever runs these packages complies with
those terms and with the law that applies to the signatures they collect.