Aller au contenu

omnisense/omnisign

Omnisign: one contract for having documents signed - create an envelope (documents, signers, fields, a level: simple, advanced, qualified), send it, follow it, remind, cancel, sign in the application's page, download the signed documents and their evidence, hear the provider's webhooks - whoever signs: Yousign (omnisign/yousign), DocuSign (omnisign/docusign), a DocuSeal instance (omnisign/docuseal); and its Symfony bundle.

Étage
Famille
Version
1.x
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnisense/omnisign:1.x-dev

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

Proposer une modification 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 with mb_substr()).
  • omnisense/omnisign requires symfony/http-client-contracts (^3.0) and nothing else.
  • Each gateway requires omnisense/omnisign and symfony/http-client (6.4, 7 or 8), and takes the application's client when it is given one; omnisign/docusign also requires ext-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

  1. Decide who signs what, and at which level: a lease, a quote, a surety.
  2. Create the envelope and keep its reference with the application's own record; send it.
  3. Let each signer sign - the provider's e-mail, or signingUrl() in the application's page (embedded: true), asked again each time.
  4. Follow it: fetch(), or the provider's webhook (notify()); remind() who is late, cancel() what is withdrawn.
  5. 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\OmnisignBundle registers the gateway factories on the application's http_client, builds the registry from config/packages/omnisign.yaml and 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 Registry once, 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.

Proposer une modification 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 (tagged omnisign.gateway_factory), and its getName() can be named as a factory (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).

Proposer une modification 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.

Proposer une modification 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():

  1. fills a Config with the options given and the factory's populateConfig() defaults under them;
  2. checks every key of omnisign.required_options is set and not empty (InvalidConfigException: The "yousign" gateway needs: api_key.);
  3. builds the client: omnisign.api, a closure of the Config called now, or an object;
  4. reads omnisign.capabilities (a Capabilities, or a closure giving one; missing: InvalidConfigException);
  5. collects every omnisign.action.<name>: an ActionInterface, or a closure of the Config giving one - or null, which leaves the action out (that is how each gateway drops notify without its webhook secret); each ApiAwareInterface action is handed the client;
  6. returns a Gateway of omnisign.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 an UnreachableException (as far as can be told, nothing was done: fetch, then try again); any other answer comes back as an Answer: status, headers (lower-case names), body, header($name), json() (the body as a JSON object, or a ProviderException).
  • Omnisign\Http\Multipart::build(array $fields, array $files): array{body, contentType} - a multipart/form-data body 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.

Proposer une modification 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) and evidence (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.

Proposer une modification 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.

Proposer une modification 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_reminder for each signer whose state is NOTIFIED; DocuSign one PUT .../recipients?resend_envelope=true with the signers neither signed nor declined (none: no call); DocuSeal PUT /submitters/{id} with send_email: true for each signer neither signed nor declined. The envelope is answered as given: fetch() it.
  • Cancel: Yousign POST .../cancel (reason other, the text as custom_note, 500 characters at most) and reads the answer; DocuSign PUT /envelopes/{id} status voided with voidedReason (the text, 200 characters at most, Cancelled by the sender without one); DocuSeal DELETE /submissions/{id} (archived). The envelope comes back CANCELED.

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.

Proposer une modification 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.

Proposer une modification 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>

Proposer une modification 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.

Proposer une modification 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.

Proposer une modification 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 MockHttpClient built 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.

Proposer une modification 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).

Proposer une modification 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, and supports() says so beforehand; a factory may leave an action out when an option is missing (a closure of the Config that returns null - as notify without a webhook secret).
  • Every action answers. execute() sets the request's result, or throws an OmnisignException; returning without a result is a ProviderException of 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 InvalidConfigException before any call.
  • Give back what to keep. create() answers the envelope with() its reference and a SignerState per signer key, holding the provider's id of each signer: the later calls find them by it. Map the provider's statuses to Status and SignerStatus, UNKNOWN for the rest.
  • Through the helpers. Call the provider with Omnisign\Http\Answer::send(): no answer, a 5xx or a 429 is then an UnreachableException; build an upload with Omnisign\Http\Multipart.
  • Errors. Refused credentials (401, 403) are an InvalidKeyException; any other refusal a ProviderException with the provider's code; a callback that does not hold an InvalidNotificationException.
  • 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.

Proposer une modification 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) and omnisign/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.
  • 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-mbstring not required though mb_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.

Proposer une modification docs/verified.md