Search by

hamada-emam-tech / esign-dropbox

hamada-emam-tech

Dropbox Sign (formerly HelloSign) driver for hamada-emam-tech/esign: e-signature requests over the Dropbox Sign API v3.

Package info

github.com/hamada-emam-tech/esign-dropbox

pkg:composer/hamada-emam-tech/esign-dropbox

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.0 2026-10-19 05:43 UTC

This package is auto-updated.

Last update: 2026-10-19 05:54:55 UTC


README

Dropbox Sign (formerly HelloSign) driver for hamada-emam-tech/esign: e-signature requests over the Dropbox Sign API v3.

Status: built from Dropbox Sign's official API documentation, not yet verified against a live account. Request and response shapes come from the API reference and the official OpenAPI spec (see Sources). If something behaves differently on your account, please open an issue.

vendor/bin/esign add dropbox          # or: composer require hamada-emam-tech/esign-dropbox

The core discovers the driver on install. You also need a PSR-18 HTTP client: Guzzle and Symfony HttpClient are picked up automatically.

Configure

$esign = new Esign([
    'default'   => 'dropbox',
    'providers' => [
        'dropbox' => [
            'api_key'   => getenv('DROPBOX_SIGN_API_KEY'),
            'test_mode' => true,                         // the default. false sends legally binding requests
            'client_id' => getenv('DROPBOX_SIGN_CLIENT_ID'),   // optional: your API app
        ],
    ],
]);
Option Default Notes
api_key required From Settings → API in Dropbox Sign. Sent as the HTTP Basic username with an empty password, and used to verify events.
test_mode true true sends test_mode=1: free, watermarked, not legally binding. Set false explicitly to go live. Must be a real boolean.
client_id none An API app. Dropbox Sign applies its branding and sends events to its callback URL.
base_url https://api.hellosign.com/v3 Must be https://.
http_client, request_factory, stream_factory auto-detected PSR-18 / PSR-17 objects.

Send

use HamadaEmamTech\Esign\Envelope\{Envelope, Document, Signer, SignatureField, IdentityMethod};

$sent = $esign->send(new Envelope(
    documents: [Document::fromPath('contract.pdf'), Document::fromPath('annex.pdf')],
    signers: [
        Signer::email('Ahmed Ali', 'ahmed@example.com', order: 1, fields: [new SignatureField(page: 1, x: 60, y: 700)]),
        Signer::email('Sara', 'sara@example.com', order: 2, fields: [new SignatureField(page: 2, x: 60, y: 100, documentIndex: 1)]),
    ],
    subject: 'Please sign the agreement',
    message: 'Thanks!',
    metadata: ['reference' => 'contract-42'],
));

$ref = (string) $sent->ref();   // "dropbox:<signature_request_id>": store this one string
$sent->signingUrls;             // always []: signers sign from Dropbox Sign's email

How the envelope is sent (POST /signature_request/send, multipart/form-data):

Envelope Dropbox Sign field
documents files[0], files[1], … (several documents per request are fine)
signer name / email signers[n][name], signers[n][email_address], in the envelope's order
signer order signers[n][order], 0-based. All signers with the same order: none sent (parallel signing). All different: 0, 1, 2… in order. A mix of both is refused.
IdentityMethod::SmsOtp signers[n][sms_phone_number] (E.164, e.g. +9665…) and signers[n][sms_phone_number_type]=authentication. Not available in test mode, so it is refused there.
IdentityMethod::Nafath refused: Dropbox Sign has no Nafath.
subject title and subject
message message
metadata metadata[key]. At most 10 keys, keys up to 40 characters, values up to 1000. Checked before sending.
fields form_fields_per_document, a JSON array, one signature field each (see below)
callbackUrl not sent: Dropbox Sign has no per-request callback. Set the account callback URL, or the API app's and pass client_id.

Everything that can be checked locally (identity, order, fields, metadata) is checked before any HTTP call.

Signature fields

SignatureField is in PDF points from the top-left of the page. With page set, Dropbox Sign uses its 72 DPI coordinate system, which is the same thing, so x and y are sent as they are (rounded). Width and height are always measured at 80 DPI, so the driver scales them by 80/72 and adds 2 to the width, as Dropbox Sign's FAQ says. A 150 × 50 pt field becomes width: 169, height: 56.

  • Give every signer at least one field, or none at all. With none, Dropbox Sign adds a signature page for everyone. A mix is refused, because the signers without a field would have nothing to sign.
  • Fields need PDF documents. Dropbox Sign's FAQ says the page-based system is designed for US Letter, and other page sizes may be placed slightly differently. Check placement in test mode first.

Capabilities

Dropbox Sign
Identity methods email, sms_otp (E.164 number; not in test mode, Standard plan or higher)
Documents per envelope no limit known to the driver (maxDocuments: null)
Feature::MultipleDocuments yes
Feature::SigningOrder yes: all signers in parallel, or a strict sequence
Feature::FieldPlacement yes, on PDFs
Feature::SigningUrls no: signers sign from Dropbox Sign's email (embedded signing is not part of this driver)
Feature::Reminders yes: $esign->remind($ref, $email)
Feature::ExpiryExtension no (see below)
Feature::WebhookManagement no: callback URLs are set on the account or the API app
Feature::SignerDetails yes: $esign->details($ref)
Cancel asynchronous (asyncCancel: true)

The core checks every envelope against this before sending, and lists every problem at once (EnvelopeNotSupported). The driver repeats its own checks, so it is safe on its own too.

Why no expiry extension: /signature_request/update accepts expires_at, but per signature_id, together with a name or email change, and not on requests with an appended signature page. The docs don't say whether it moves the whole request. Rather than guess, the driver leaves it out.

Webhook, then status, then download

Dropbox Sign posts events as multipart/form-data with the event JSON in a field named json. Your endpoint must answer HTTP 200 with a body containing Hello API Event Received, for every event, even an invalid one. Otherwise Dropbox Sign retries (up to 6 times, over about 30 hours) and clears your callback URL after 10 consecutive failures. The driver implements CustomizesWebhookResponse, so $esign->handleWebhook() returns that response for you:

// POST /webhooks/dropbox
$result = $esign->handleWebhook($request->headers->all(), $request->getContent(), 'dropbox');   // never throws
if ($result->isValid()) {
    dispatch(new SyncEnvelope((string) $result->ref()));      // "dropbox:<signature_request_id>"
} else {
    logger()->warning('Bad Dropbox Sign event', ['error' => $result->error?->getMessage()]);
}
return response($result->response->body, $result->response->status, $result->response->headers);
// always 200 "Hello API Event Received"

// In the job:
$status = $esign->status($ref);
if ($status === EnvelopeStatus::Signed) {
    $pdf = $esign->download($ref);
}

$details = $esign->details($ref);       // each signer: email, name, Pending | Signed | Declined, order
foreach ($details->pending() as $signer) {
    $esign->remind($ref, $signer->email);  // Dropbox Sign allows one reminder an hour
}

The body can be the raw multipart body (pass the Content-Type header so the boundary is known; otherwise it is read from the body), a url-encoded json=… body, or the bare event JSON. Frameworks that parse multipart for you leave getContent() empty: pass $request->input('json') as the body instead.

Verification: event.event_hash must equal HMAC-SHA256(event_time . event_type) keyed with your API key, in hex. It is compared in constant time, and with no API key every event is refused. A verified event that isn't about a signature request (callback_test, account or template events) is refused too. handleWebhook() still answers it with the required body.

The driver only takes signature_request.signature_request_id from the event. It never decides the status from it: status() asks the API.

Status mapping

status() calls GET /signature_request/{id} and decides in this order:

Dropbox Sign EnvelopeStatus
HTTP 410, error_name: deleted (cancelled or deleted) Cancelled
error_name: signature_request_expired Expired
has_error: true ("an error occurred" creating the request or during signing) Failed
is_declined: true, or any signature declined Declined
is_complete: true and every signature signed Signed
any signature expired, or expires_at in the past Expired
awaiting_signature / on_hold, with some signed PartiallySigned
awaiting_signature / on_hold, nobody signed Sent
any other status_code throws ProviderError. Please report it.

The reference only lists awaiting_signature, signed and declined as examples of status_code. on_hold comes from the "release on-hold signature request" docs, and expired from the expires_at description ("unsigned signatures will be moved to the expired status").

Signer states in details(): signed → Signed, declined → Declined, anything else (awaiting_signature, on_hold, expired) → Pending. order is Dropbox Sign's own 0-based order (null for parallel signing), and signingUrl is always null. A cancelled or expired request answers with an error and no signer list, so its details have signers: [].

Cancel, remind and download

  • cancel() calls POST /signature_request/cancel/{id}. Dropbox Sign only queues the cancel: confirmation is the signature_request_canceled event, and status() says Cancelled once it has gone through. A request that everyone has signed can't be cancelled (signature_request_cancel_failed).
  • remind() calls POST /signature_request/remind/{id} with {"email_address": …} (JSON, as documented). Dropbox Sign refuses a reminder within an hour of the previous one, manual or automatic, and for embedded requests; that comes back as a ProviderError.
  • download() runs only when status() is Signed, then calls GET /signature_request/files/{id}?file_type=pdf, which returns the merged PDF itself (not a URL). While the PDF is still being prepared Dropbox Sign answers 409. That becomes a ProviderError with httpStatus 409 and "still being prepared": retry shortly, or wait for signature_request_downloadable.

Errors

Every 4xx/5xx becomes a ProviderError carrying the HTTP status and Dropbox Sign's error_name as providerCode (bad_request, unauthorized, not_found, conflict, exceeded_rate, …), with error_msg in the message. The API key never appears in a message: it is scrubbed even if something echoes it back.

Testing without real signers

Use the core's fake provider in your app's tests:

$esign = new Esign(['default' => 'fake']);
$fake = $esign->provider();
$sent = $esign->send($envelope);
$fake->sign($sent->envelopeId);

The package's own tests run against tests/Support/FakeDropbox.php, a PSR-18 fake built from the documented request and response shapes.

Sources