{
  "source_file": "0930",
  "source_platform": "Hashnode",
  "author": "Jaival Suthar",
  "title_listed": "From Deep Links to Distributed Messaging: Engineering Secure Document Sharing Workflows",
  "keywords": "",
  "domain": "文本处理",
  "content_type": "深度技术解析",
  "is_paid_flag": "否",
  "url": "https://jaivalsuthar.hashnode.dev/from-deep-links-to-distributed-messaging",
  "publish_date": "2026-05-28",
  "corpus_stage": "",
  "notes": "",
  "id": "4829f9c54fce",
  "collected_at": "2026-10-02T14:12:16",
  "title": "Engineering Secure Document Sharing Workflows",
  "body_markdown": "From Deep Links to Distributed Messaging: Engineering Secure Document Sharing Workflows\nUpdated\nMay 28, 2026\n•\n13\nmin read\n•\nView as Markdown\nJ\nJaival Suthar\nI write technical articles that analyze real world engineering problems through the lens of production systems.\nMy work focuses on architectural tradeoffs, UI layer system design, performance optimization, and reliability patterns derived from shipping and maintaining scalable applications.\nThe feature request read like it would take a day:\n\"Let HR teams share candidate profiles and resumes directly to hiring managers on WhatsApp.\"\nWhat emerged was a queue-backed, webhook-driven, delivery-tracked messaging system with failure isolation, group management, and a document persistence model I hadn't expected to care about at all. This is that story.\nThe Naive Path, and Why It Breaks\nThe first prototype was obvious: generate a temporary token, store it in the database with an expiry, and construct a WhatsApp deep link that opens a pre-filled message.\nhttps://wa.me/?text=Check this candidate: https://yourapp.com/share/abc123\nElegant. Zero external dependencies. Ships in a day.\nI tested it. It worked. And then I watched it break the product requirement entirely.\nThe deep link didn't open a contact picker or a structured dialog. It opened the sender's full WhatsApp window, every conversation, every contact, every group, with the pre-filled text sitting in the input box of whatever chat they had last opened. For any system dealing with sensitive HR data and candidate PII, that's not a UX problem. That's a security and compliance failure.\nThere was a subtler problem too, one that only becomes visible when you think about what happens on the recipient's end.\nWhen you share a link over WhatsApp, you're sharing a URL. The recipient opens it in a browser. That browser needs to authenticate, or the document is publicly exposed to anyone with the link. One path leaks data. The other makes the link useless to unauthorized recipients. You can engineer around this with short-lived signed URLs, but now your link has an expiry.\nThis is actually a core architectural divergence between how WhatsApp deep links and the WhatsApp Business API handle documents, and it's worth understanding precisely because it changes your entire storage and delivery model.\nHow WhatsApp Actually Handles Documents\nThink about the last time a bank or an e-commerce platform sent you a receipt or a statement on WhatsApp. You open the chat weeks later, and the PDF is still there. It hasn't expired. You didn't need to authenticate anywhere. The document just... persists.\nThis is not accidental. It reflects how the Business API handles media at the protocol level.\nWhen you send a document via the WhatsApp Business API, you first upload the file to Meta's media servers. Meta returns a\nmedia_id\n. You then send a message referencing that\nmedia_id\n. WhatsApp downloads and hosts the document on its own infrastructure. From that point forward, the document's availability is decoupled from your system entirely.\nThis is the fundamental tradeoff: with a deep link,\nyou\nown the document URL, which means you also own its availability, authentication, and expiry. The moment the token expires or you revoke it, the link breaks. With the Business API, Meta's servers own the document after upload. Delivery becomes durable by design.\nThis is exactly why regulated industries like banking, insurance, and healthcare use the Business API for sending sensitive documents. Not primarily for the branding or the delivery receipts, but for this simple reason:\nthe document's persistence is not a function of your infrastructure's uptime or your token's TTL.\nRethinking the Architecture\nWith deep links eliminated, the problem reframed itself. This was no longer about generating a shareable URL. It was about building a controlled, auditable messaging pipeline that could:\nSend documents to specific individuals inside a defined group\nTrack delivery at the message level (sent, delivered, read, failed)\nRetry failed sends without human intervention\nAccept status updates asynchronously via webhooks\nIsolate failures per recipient, not per batch\nThe HR use case made this concrete. A hiring manager uploads a candidate. They want to notify a panel of four interviewers. If one interviewer's phone number is invalid, the other three should still receive the message. Failure should not be contagious.\nHere is how the system was architected.\nGroup Abstraction: The Core Data Model\nThe most important design decision was introducing a\ngroup\nabstraction between HR users and phone numbers.\nRather than letting senders specify individual phone numbers at send time, which introduces human error, no audit trail, and no centralized control, the system maintains logical groups of recipients. An HR manager selects a group (\"Senior Panel,\" \"Founding Team,\" \"External Recruiters\") and the system resolves that to its current set of active members.\nThis has a number of downstream benefits that aren't obvious until you think through operational scenarios:\nMembership changes don't require UI changes.\nWhen a new interviewer joins the panel, an admin adds them to the group. All future shares automatically include them. No one needs to update a contact list in a form.\nRevocation is group-scoped.\nIf a group member leaves the organization, deactivating them in the group immediately removes them from future sends. Historical logs still retain their record.\nAudit is clean.\nEvery send is logged against a group and a specific member within that group, with timestamps for each status transition. You can reconstruct exactly who was notified about a candidate, when, and whether they received it.\nThe Queue and Why It Matters\nSending to a group of five people looks trivially parallelizable. It isn't, for a few reasons.\nMeta's Business API has rate limits. Bursting requests synchronously during an HTTP request handler risks 429 responses that you then have to handle inline, adding latency to what should be a fast user action. More importantly, network failures and API errors should trigger\nretries with backoff\n, not immediate failure surfaces to the user.\nThe right model is: the user action enqueues a job. The HTTP response returns immediately. A background worker processes the queue asynchronously, handles each recipient independently, and records outcomes.\nEach recipient message gets its own log entry, with its own status lifecycle. If three messages succeed and one fails due to an invalid number, the job still transitions to DONE and the failed message carries its own error state. The failure is isolated.\nThe retry logic deserves mention. Simple retry-on-failure is easy to implement and easy to get wrong. Without backoff, a worker hammering a temporarily unavailable API endpoint creates more load at exactly the wrong moment. The retry count column in the queue table drives an exponential delay calculation: first retry after 30 seconds, second after 2 minutes, third after 10 minutes. After the configured maximum, the record is marked FAILED and surfaced to the HR administrator for manual review.\nWebhook-Based Status Updates\nSending a message is half the problem. Knowing whether it was delivered and read is the other half.\nThe WhatsApp Business API is push-based for status updates. After sending a message, Meta calls your registered webhook URL with status change events:\nsent\n,\ndelivered\n,\nread\n,\nfailed\n. Each event carries the\nmeta_message_id\nthat you stored when you sent the message.\nThis creates a clean two-phase model:\nPhase 1 (outbound):\nYour worker sends the message, receives the\nmeta_message_id\n, and stores it alongside the log entry with status\nSENT\n.\nPhase 2 (inbound):\nMeta calls your webhook when status changes. You look up the log entry by\nmeta_message_id\nand update the status and timestamp accordingly.\nThe webhook handler needs to be idempotent. Meta can deliver the same event more than once. Updating a record to the same status it already has is harmless; inserting a duplicate is not. Always upsert or check-before-update in your webhook logic.\nOne operational detail worth flagging: webhook endpoints must respond with HTTP 200 within a few seconds or Meta will retry delivery. Any non-trivial processing, such as database writes or downstream notifications, should happen synchronously but fast, or be handed off to another queue. Webhook handlers that time out create cascading retry storms.\nDocument Handling: CloudFront and S3 Presigned URLs\nThe resume files live in S3, served via CloudFront. Before sending via the Business API, the worker needs to:\nFetch the binary content of the resume from S3\nUpload it to Meta's media endpoint\nUse the returned\nmedia_id\nin the message payload\nThe fetch step uses a short-lived S3 presigned URL generated by the worker at runtime. This URL is never exposed to the client or stored anywhere. It exists only long enough for the worker to pull the bytes and upload them to Meta. Once Meta has the file, the presigned URL can expire and the document's availability is entirely Meta's responsibility.\nThis is a clean separation: S3 holds the canonical, access-controlled copy. Meta holds the WhatsApp-deliverable copy. Neither leaks to unauthenticated users, and neither's availability depends on the other.\nWhat the Developer API Prototype Revealed\nThe system was built and validated against Meta's developer test environment, which provides a free test number valid for 90 days. This is not a staging environment in the traditional sense. It's a real API surface with real rate limits, real webhook behavior, and real template approval requirements.\nTemplate approval was the first friction point, and understanding\nwhy\nit exists changes how you think about the entire platform.\nWhatsApp is fundamentally a consent-based messaging channel. Unlike email, where anyone can send anything to any address, WhatsApp distinguishes sharply between two conversation types. A user-initiated conversation, where a person messages a business first, opens a 24-hour window during which the business can reply freely. A business-initiated conversation, where the business reaches out to a user who has not messaged them, is only permitted using pre-approved message templates.\nThis distinction exists to prevent spam at platform scale. Meta reviews every template before it can be used to initiate a conversation. The review covers message structure, variable usage, and the category of content being sent. Templates that could be used for promotional spam, misleading claims, or unverifiable content are rejected. Approved templates are locked. You cannot modify the body text after approval; you can only populate the defined variable placeholders at send time.\nFor our candidate sharing use case, this meant the message structure had to be decided and approved before a single message could be sent. The template carried structured candidate details as numbered variables alongside a document component for the resume. Each variable maps to a specific piece of candidate data populated by the worker at send time. Getting this approved required submitting the template through Meta's Business Manager, waiting for review, and then referencing the approved template name and language code in every API call.\nThis is not a developer inconvenience. It is a deliberate platform constraint that has real implications for your delivery timeline. Unlike most API integrations where you can ship and iterate freely, the WhatsApp Business API requires you to design your message structure upfront and get it approved before you can test with real numbers outside the developer sandbox. Any structural change to an approved template requires submitting a new template for review. Plan for this lead time explicitly.\nThe 90-day prototype window made one thing clear: the Business API is enterprise infrastructure, not a developer utility. The approval flows, the tiered access model, and the conversation-based billing are all designed for organizations sending at scale, with compliance teams and dedicated integration engineers. Building a prototype is accessible. Productionizing it requires organizational alignment that goes beyond the engineering work.\nWhat the Prototype Validated\nAlthough the implementation remained at prototype stage due to shifting product priorities, the architecture and integration were validated end-to-end against Meta's developer environment. The queue processed jobs correctly, webhooks updated delivery status in real time, group resolution handled member deactivation cleanly, and failure isolation behaved exactly as designed. The engineering outcomes were complete.\nThis is a normal part of product development. Priorities shift, roadmaps change, and not every validated prototype reaches production. What matters is that the architectural decisions were sound and the tradeoffs were understood.\nThe most durable insight from this work was the document persistence model. The hard part turned out not to be the API integration. It was understanding why the Business API exists for this use case in the first place. Uploading to Meta's media servers transfers durability responsibility entirely away from your infrastructure. When an insurance company sends you a policy document on WhatsApp, they are not managing token expiry or authentication. They have offloaded that concern to a platform built to handle it at scale.\nThat understanding shapes how you evaluate every future decision involving document sharing, regardless of the delivery channel.\nKey Architectural Decisions in Summary\nGroup abstraction over direct phone numbers\n- Centralizes membership management, enables clean revocation, produces auditable send history without requiring senders to manage contact lists.\nQueue-first, synchronous-never\n- User actions enqueue work and return immediately. Background workers handle API calls, retries, and status updates. The user experience is decoupled from the reliability of an external API.\nPer-recipient failure isolation\n- A failed send to one group member does not fail the batch. Each message log entry carries its own status and error state independently.\nWebhook-driven status\n- Delivery and read receipts arrive asynchronously via Meta's webhook infrastructure. The message log is the single source of truth for status, updated by both the outbound worker and the inbound webhook handler.\nEphemeral presigned URLs for media transfer\n- The worker generates short-lived S3 URLs at send time to fetch resume bytes. These are never stored or exposed. After upload to Meta, document availability is Meta's responsibility.\nDeep link vs. Business API\n- Deep links are free and zero-dependency, but expose the sender's full contact list and produce links that expire. The Business API requires template approval and conversation-based billing, but delivers durable documents that persist in chat and never require recipients to authenticate anywhere.\nThe right choice between them is not a technical question. It's a product question about who your recipients are, what your compliance requirements look like, and whether your organization is willing to operate inside Meta's approval and billing model. For an internal HR platform sharing candidate documents with a controlled list of recipients, the Business API was the only path that satisfied the actual requirements.\nAlthough the feature was not ultimately released due to shifting product priorities, the implementation was fully built, integrated, and validated end-to-end against Meta’s WhatsApp Business API developer environment. The system handled queue-backed delivery orchestration, webhook-driven status tracking, retry workflows, and group-scoped failure isolation as designed.\n#\nbackend\n#\nsystem-design\n#\nnodejs\n#\naws\n#\nredis\n#\ndistributed-system",
  "metadata": {
    "title": "Engineering Secure Document Sharing Workflows",
    "description": "How a simple WhatsApp sharing feature evolved into a queue-backed, webhook-driven distributed messaging workflow",
    "keywords": null,
    "author": null,
    "og:title": "Engineering Secure Document Sharing Workflows",
    "og:description": "How a simple WhatsApp sharing feature evolved into a queue-backed, webhook-driven distributed messaging workflow",
    "og:url": "https://jaivalsuthar.hashnode.dev/from-deep-links-to-distributed-messaging",
    "og:site_name": "Jaival Suthar | Engineering Portfolio",
    "og:image": "https://cdn.hashnode.com/uploads/og-images/6972fc71207454ddbf7903dd/4849aad6-a308-4ca4-a8b5-7bf1c64f2519.png",
    "og:image:width": "1200",
    "og:image:height": "630",
    "og:type": "article",
    "twitter:card": "summary_large_image",
    "twitter:creator": "https://x.com/Jaival_Suthar",
    "twitter:title": "Engineering Secure Document Sharing Workflows",
    "twitter:description": "How a simple WhatsApp sharing feature evolved into a queue-backed, webhook-driven distributed messaging workflow",
    "twitter:image": "https://cdn.hashnode.com/uploads/og-images/6972fc71207454ddbf7903dd/4849aad6-a308-4ca4-a8b5-7bf1c64f2519.png",
    "article:published_time": "2026-05-28T10:18:24.395Z",
    "article:modified_time": "2026-05-28T10:18:24.395Z",
    "article:author": "https://jaivalsuthar.hashnode.dev/@JaivalSuthar"
  },
  "paywall": {
    "listed_paid": false,
    "detected": false,
    "marker": "",
    "public_only": false,
    "policy": "付费/会员内容只保留页面公开可见部分，不绕过付费墙"
  },
  "crawl": {
    "success": true,
    "status_code": 200,
    "error": ""
  },
  "code_blocks": [
    {
      "file": "attachments/4829f9c54fce/code/body-000.txt",
      "chars": 75,
      "language": "text"
    }
  ],
  "images": [
    {
      "status": "ok",
      "url": "https://cdn.hashnode.com/uploads/covers/6972fc71207454ddbf7903dd/b2308955-8029-46f6-99b6-4e6b5f071de9.png",
      "bytes": 1246019,
      "reused": true,
      "local_path": "attachments/4829f9c54fce/images/000.png",
      "src": "https://cdn.hashnode.com/uploads/covers/6972fc71207454ddbf7903dd/b2308955-8029-46f6-99b6-4e6b5f071de9.png",
      "alt": "From Deep Links to Distributed Messaging: Engineering Secure Document Sharing Workflows",
      "downloaded": true
    },
    {
      "status": "ok",
      "url": "https://cdn.hashnode.com/uploads/covers/6972fc71207454ddbf7903dd/2f25f8fe-33cf-40e0-9a61-8ca922c70038.png",
      "bytes": 243851,
      "reused": true,
      "local_path": "attachments/4829f9c54fce/images/001.png",
      "src": "https://cdn.hashnode.com/uploads/covers/6972fc71207454ddbf7903dd/2f25f8fe-33cf-40e0-9a61-8ca922c70038.png",
      "alt": "",
      "downloaded": true
    },
    {
      "status": "ok",
      "url": "https://cdn.hashnode.com/uploads/covers/6972fc71207454ddbf7903dd/e3d5ca9d-8c91-42a7-9c70-1f7ab4d55cf0.png",
      "bytes": 224389,
      "reused": true,
      "local_path": "attachments/4829f9c54fce/images/002.png",
      "src": "https://cdn.hashnode.com/uploads/covers/6972fc71207454ddbf7903dd/e3d5ca9d-8c91-42a7-9c70-1f7ab4d55cf0.png",
      "alt": "",
      "downloaded": true
    },
    {
      "status": "ok",
      "url": "https://cdn.hashnode.com/uploads/covers/6972fc71207454ddbf7903dd/04884ef4-29fd-48bc-a2c5-a79493408eea.png",
      "bytes": 250631,
      "reused": true,
      "local_path": "attachments/4829f9c54fce/images/003.png",
      "src": "https://cdn.hashnode.com/uploads/covers/6972fc71207454ddbf7903dd/04884ef4-29fd-48bc-a2c5-a79493408eea.png",
      "alt": "",
      "downloaded": true
    }
  ],
  "audio": [],
  "videos": [],
  "links": [
    {
      "href": "https://jaivalsuthar.hashnode.dev/from-deep-links-to-distributed-messaging.md",
      "label": "View as Markdown",
      "type": "a"
    },
    {
      "href": "https://hashnode.com/@JaivalSuthar",
      "label": "J",
      "type": "a"
    },
    {
      "href": "https://x.com/Jaival_Suthar",
      "label": "",
      "type": "a"
    },
    {
      "href": "https://www.linkedin.com/in/jaival-suthar-57195b257/",
      "label": "",
      "type": "a"
    },
    {
      "href": "https://jaivalsuthar.hashnode.dev/tag/backend",
      "label": "# backend",
      "type": "a"
    },
    {
      "href": "https://jaivalsuthar.hashnode.dev/tag/system-design",
      "label": "# system-design",
      "type": "a"
    },
    {
      "href": "https://jaivalsuthar.hashnode.dev/tag/nodejs",
      "label": "# nodejs",
      "type": "a"
    },
    {
      "href": "https://jaivalsuthar.hashnode.dev/tag/aws",
      "label": "# aws",
      "type": "a"
    },
    {
      "href": "https://jaivalsuthar.hashnode.dev/tag/redis",
      "label": "# redis",
      "type": "a"
    },
    {
      "href": "https://jaivalsuthar.hashnode.dev/tag/distributed-system",
      "label": "# distributed-system",
      "type": "a"
    }
  ],
  "retry_at": "2026-10-03T08:10:39",
  "repair_version": 2,
  "repaired_at": "2026-10-14T10:51:03Z",
  "source_url": "https://jaivalsuthar.hashnode.dev/from-deep-links-to-distributed-messaging",
  "papers": [],
  "actual_video_files": [],
  "body_chars": 15872,
  "expected_body_images": 4,
  "discovery_status": "ok",
  "delivery_status": "ok",
  "missing_reason": "",
  "raw_html": "attachments/4829f9c54fce/pages/source.html",
  "local_html": "attachments/4829f9c54fce/pages/article.html",
  "body_base_path": "attachments/4829f9c54fce/pages/",
  "body_markdown_path": "attachments/4829f9c54fce/pages/article.md"
}