Skip to main content
RapidDev - Software Development Agency
API AutomationsStripeAPI Key

How to Automate Stripe Payment Notifications using the API

Automate Stripe payment notifications by setting up webhooks for payment_intent.succeeded, charge.failed, and charge.dispute.created events. Stripe POSTs to your endpoint; verify the Stripe-Signature header using stripe.webhooks.constructEvent() with the RAW request body — parsing JSON first breaks verification. Then forward to Slack, email, or SMS. Rate limit: Stripe retries failed webhooks for up to 3 days.

Need help automating? Talk to an expert
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner7 min read15-30 minutesStripeMay 2026RapidDev Engineering Team
TL;DR

Automate Stripe payment notifications by setting up webhooks for payment_intent.succeeded, charge.failed, and charge.dispute.created events. Stripe POSTs to your endpoint; verify the Stripe-Signature header using stripe.webhooks.constructEvent() with the RAW request body — parsing JSON first breaks verification. Then forward to Slack, email, or SMS. Rate limit: Stripe retries failed webhooks for up to 3 days.

API Quick Reference

Auth

API Key (Bearer token) + Webhook Signing Secret

Rate limit

No limit on incoming webhooks; API calls 100 req/sec (live mode)

Format

JSON

SDK

Available

Understanding the Stripe API

Stripe's webhook system is the backbone of real-time payment notifications. Instead of polling Stripe for payment status, you register an HTTPS endpoint and Stripe POSTs event objects to it within seconds of each payment event. This event-driven architecture is more reliable, more efficient, and covers events that have no polling equivalent — like dispute creation or refund failures.

The key events for payment notifications: payment_intent.succeeded (payment completed), charge.failed (card declined), charge.dispute.created (chargeback filed), checkout.session.completed (Checkout flow finished), and invoice.paid / invoice.payment_failed for subscription billing.

Every webhook POST includes a Stripe-Signature header built from your webhook signing secret (whsec_*). Verifying this signature before processing any event is mandatory — it's your only protection against forged webhook requests. The #1 implementation error is calling JSON.parse() or request.json on the body before passing it to constructEvent(), which corrupts the signature verification. Official documentation: https://stripe.com/docs/webhooks

Base URLhttps://api.stripe.com

Setting Up Stripe API Authentication

Payment notifications require two credentials: your API secret key (sk_*) for making API calls, and a webhook signing secret (whsec_*) for verifying incoming webhook events. These are separate — the signing secret is specific to each webhook endpoint you register in Stripe.

  1. 1Log in to your Stripe Dashboard at dashboard.stripe.com
  2. 2Go to Developers > API keys and copy your secret key (sk_live_* for production)
  3. 3Set STRIPE_SECRET_KEY environment variable
  4. 4Go to Developers > Webhooks > Add endpoint
  5. 5Enter your endpoint URL (must be HTTPS in production) and select events: payment_intent.succeeded, charge.failed, charge.dispute.created, checkout.session.completed
  6. 6Click 'Add endpoint' — Stripe creates the webhook and shows a Signing Secret (whsec_...)
  7. 7Set STRIPE_WEBHOOK_SECRET environment variable with the whsec_* value
  8. 8For local development, use Stripe CLI: stripe listen --forward-to localhost:3000/webhooks/stripe
auth.py
1import os
2import stripe
3
4stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
5WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')
6
7# List registered webhook endpoints
8endpoints = stripe.WebhookEndpoint.list(limit=5)
9for ep in endpoints.data:
10 print(f'Endpoint: {ep.url}')
11 print(f'Events: {ep.enabled_events}')
12 print(f'Status: {ep.status}')

Security notes

  • Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in environment variables — never hardcode
  • Each webhook endpoint has its own signing secret — use the correct secret for each endpoint
  • Always verify the Stripe-Signature header before processing any webhook event
  • Use the raw request body for signature verification — never parse JSON first
  • Reject events older than 5 minutes (Stripe SDK does this by default) to prevent replay attacks
  • Never expose your webhook signing secret (whsec_*) to frontend code or logs

Key endpoints

POST/v1/webhook_endpoints

Programmatically registers a webhook endpoint. Alternatively, register manually in the Stripe Dashboard. Returns the endpoint object including the signing secret.

ParameterTypeRequiredDescription
urlstringrequiredHTTPS URL of your webhook endpoint. Must be publicly accessible (not localhost).
enabled_eventsarrayrequiredList of event types to send to this endpoint. Use ['*'] to receive all events.

Request

json
1{"url": "https://yourapp.com/webhooks/stripe", "enabled_events": ["payment_intent.succeeded", "charge.failed", "charge.dispute.created", "checkout.session.completed"]}

Response

json
1{"id": "we_1OqKLt2eZvKYlo2C", "object": "webhook_endpoint", "url": "https://yourapp.com/webhooks/stripe", "status": "enabled", "enabled_events": ["payment_intent.succeeded", "charge.failed", "charge.dispute.created"], "secret": "whsec_AbCdEfGhIjKlMnOpQrStUv"}
GET/v1/events/{id}

Retrieves a specific event by ID. Useful for reprocessing missed events or debugging webhook handlers without waiting for Stripe to resend.

ParameterTypeRequiredDescription
idstringrequiredThe event ID (evt_*) to retrieve

Response

json
1{"id": "evt_1OqKLt2eZvKYlo2C", "object": "event", "type": "payment_intent.succeeded", "created": 1706745700, "livemode": true, "data": {"object": {"id": "pi_1OqKLt", "amount": 2000, "currency": "usd", "status": "succeeded", "customer": "cus_PqKLt2", "metadata": {"order_id": "ORD-881"}}}}
GET/v1/events

Lists events with optional filtering by type and date. Use this for backfilling missed events or debugging. Events are retained for 30 days.

ParameterTypeRequiredDescription
typestringoptionalFilter by event type, e.g. 'payment_intent.succeeded'
created[gte]numberoptionalUnix timestamp — return events after this time
limitnumberoptionalRecords per page, max 100

Response

json
1{"object": "list", "data": [{"id": "evt_1OqKLt", "type": "payment_intent.succeeded", "created": 1706745700}], "has_more": true}
POST/v1/webhook_endpoints/{id}

Updates a webhook endpoint — change the URL, add/remove event subscriptions, or enable/disable it.

ParameterTypeRequiredDescription
enabled_eventsarrayoptionalUpdated list of event types for this endpoint
urlstringoptionalNew URL for the endpoint
disabledbooleanoptionalSet to true to temporarily disable the endpoint without deleting it

Request

json
1{"enabled_events": ["payment_intent.succeeded", "charge.failed", "charge.dispute.created", "checkout.session.completed", "invoice.paid"]}

Response

json
1{"id": "we_1OqKLt2eZvKYlo2C", "status": "enabled", "enabled_events": ["payment_intent.succeeded", "charge.failed", "charge.dispute.created", "checkout.session.completed", "invoice.paid"]}

Step-by-step automation

1

Set Up the Webhook Endpoint with Signature Verification

Why: Signature verification is the security foundation — without it, anyone can POST fake payment events to your endpoint and trigger your business logic.

Register your HTTPS endpoint in the Stripe Dashboard for the events you care about. In your handler, use express.raw() or equivalent to preserve the raw request body, then call stripe.webhooks.constructEvent(rawBody, signature, webhookSecret) before touching any event data. If verification fails, return 400 immediately.

request.sh
1# Register webhook endpoint via API
2curl https://api.stripe.com/v1/webhook_endpoints \
3 -u "$STRIPE_SECRET_KEY:" \
4 -d url="https://yourapp.com/webhooks/stripe" \
5 -d "enabled_events[]"="payment_intent.succeeded" \
6 -d "enabled_events[]"="charge.failed" \
7 -d "enabled_events[]"="charge.dispute.created" \
8 -d "enabled_events[]"="checkout.session.completed"

Pro tip: Run stripe listen --forward-to localhost:3000/webhooks/stripe with the Stripe CLI during development to test webhooks without needing a public URL. It also shows all event payloads in real-time.

Expected result: Incoming Stripe webhooks are verified and the event object is available for processing. Forged or corrupted requests return 400.

2

Route Events and Send Notifications

Why: Different payment events require different notification actions — successes go to Slack/email, failures trigger customer support alerts, disputes require immediate attention.

Switch on event.type to route each event to the correct notification handler. The data.object field contains the full payment object (PaymentIntent, Charge, or Dispute). Extract the amount, customer email, and metadata you stored on the payment to build meaningful notification messages.

request.sh
1# Send a Slack notification (use from within your webhook handler)
2curl -X POST $SLACK_WEBHOOK_URL \
3 -H 'Content-Type: application/json' \
4 -d '{"text": "Payment succeeded: USD 29.00 from customer@example.com"}'

Pro tip: Use different Slack channels for different event types: #payments for successes, #payment-failures for declines, #disputes for chargebacks. Disputes need immediate attention — send them to a PagerDuty or phone alert channel.

Expected result: Slack (or email/SMS) receives formatted notifications for each payment event type within seconds of the event occurring.

3

Handle Webhook Retries Idempotently

Why: Stripe retries webhooks for up to 3 days on failures — your handler must be idempotent to avoid processing the same payment event twice.

Store processed event IDs in your database or cache. Before processing, check if event.id has already been handled. Stripe guarantees event.id is unique per event — using it as an idempotency key prevents double-notifications, double-grants, and double-database writes when Stripe retries.

request.sh
1# Retrieve a specific event to manually replay it
2curl https://api.stripe.com/v1/events/evt_1OqKLt2eZvKYlo2C \
3 -u "$STRIPE_SECRET_KEY:"

Pro tip: Store processed event IDs in Redis with a 24-hour TTL using SET ... NX EX 86400. The NX flag (only set if not exists) is atomic — no race conditions in concurrent webhook processing.

Expected result: Duplicate webhook deliveries (from Stripe retries) are safely ignored without triggering duplicate notifications or database writes.

4

Backfill Missed Events

Why: If your webhook endpoint was down, Stripe retries for 3 days — but after that, events are lost. Use GET /v1/events to recover them.

Query GET /v1/events with a created[gte] timestamp from when your endpoint went down. Filter by event type to get only the events you need to reprocess. This works for up to 30 days back — Stripe retains events for 30 days. Pass each event through your handler to backfill any missed notifications or database updates.

request.sh
1# Get all payment_intent.succeeded events from the last 24 hours
2curl "https://api.stripe.com/v1/events?type=payment_intent.succeeded&created[gte]=1706659200&limit=100" \
3 -u "$STRIPE_SECRET_KEY:"

Pro tip: Your idempotency check (step 3) protects against reprocessing events that were already handled before the outage — run backfill without worrying about duplicates.

Expected result: All payment events from the specified time window are retrieved and processed, backfilling any notifications or database updates that were missed during downtime.

Complete working code

A complete Stripe webhook handler that verifies signatures, routes events to notification channels, handles idempotency with Redis, and responds within Stripe's 30-second timeout. Includes handlers for payments, failures, and disputes.

automate_stripe_notifications.py
1import stripe
2import os
3import logging
4import requests
5from flask import Flask, request, jsonify
6
7logging.basicConfig(level=logging.INFO)
8log = logging.getLogger(__name__)
9
10stripe.api_key = os.environ['STRIPE_SECRET_KEY']
11WEBHOOK_SECRET = os.environ['STRIPE_WEBHOOK_SECRET']
12SLACK_PAYMENTS = os.environ.get('SLACK_PAYMENTS_WEBHOOK')
13SLACK_DISPUTES = os.environ.get('SLACK_DISPUTES_WEBHOOK')
14
15app = Flask(__name__)
16
17# Simple in-memory dedup (use Redis in production)
18processed = set()
19
20def slack(webhook_url, message):
21 if webhook_url:
22 try:
23 requests.post(webhook_url, json={'text': message}, timeout=5)
24 except Exception as e:
25 log.error(f'Slack failed: {e}')
26
27def handle_event(event):
28 if event.id in processed:
29 log.info(f'Duplicate: {event.id}')
30 return
31 processed.add(event.id)
32
33 obj = event.data.object
34
35 if event.type == 'payment_intent.succeeded':
36 amt = f"{obj.currency.upper()} {obj.amount/100:.2f}"
37 email = obj.get('receipt_email') or obj.metadata.get('email', 'unknown')
38 order = obj.metadata.get('order_id', '')
39 msg = f'Payment succeeded: {amt} from {email}' + (f' — Order {order}' if order else '')
40 log.info(msg)
41 slack(SLACK_PAYMENTS, msg)
42
43 elif event.type == 'charge.failed':
44 amt = f"{obj.currency.upper()} {obj.amount/100:.2f}"
45 reason = obj.failure_message or obj.failure_code or 'card_declined'
46 msg = f'Payment FAILED: {amt} — {reason}'
47 log.warning(msg)
48 slack(SLACK_PAYMENTS, msg)
49
50 elif event.type == 'charge.dispute.created':
51 amt = f"{obj.currency.upper()} {obj.amount/100:.2f}"
52 msg = f'DISPUTE ALERT: {amt} — Reason: {obj.reason}. Deadline: respond within 7 days!'
53 log.error(msg)
54 slack(SLACK_DISPUTES, msg)
55
56 elif event.type == 'checkout.session.completed':
57 total = f"{obj.currency.upper()} {obj.amount_total/100:.2f}"
58 msg = f'Checkout completed: {obj.customer_email} — {total}'
59 log.info(msg)
60 slack(SLACK_PAYMENTS, msg)
61
62@app.route('/webhooks/stripe', methods=['POST'])
63def webhook():
64 payload = request.get_data() # Raw bytes
65 sig = request.headers.get('Stripe-Signature')
66
67 try:
68 event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
69 except Exception as e:
70 log.warning(f'Webhook rejected: {e}')
71 return jsonify({'error': str(e)}), 400
72
73 try:
74 handle_event(event)
75 except Exception as e:
76 log.error(f'Handler error for {event.id}: {e}')
77 return jsonify({'error': 'Processing failed'}), 500
78
79 return jsonify({'received': True})
80
81if __name__ == '__main__':
82 app.run(port=3000)

Error handling

400No signatures found matching the expected signature for payload
Cause

The request body was parsed (JSON.parse or request.json) before being passed to constructEvent(). Parsing modifies the raw bytes and breaks HMAC verification.

Fix

Use express.raw({type: 'application/json'}) in Node.js or request.get_data() in Flask. The webhook route must receive the raw body bytes. If using body-parser globally, define the webhook route before the body-parser middleware.

Retry strategy

No retry — fix the middleware configuration

400Timestamp outside the tolerance zone
Cause

The webhook event timestamp is older than 5 minutes (Stripe SDK default tolerance). This protects against replay attacks. Can also happen if your server clock is wrong.

Fix

Ensure your server's system clock is synchronized with NTP. If you need to extend tolerance for testing, pass tolerance parameter to constructEvent(). Never disable tolerance entirely in production.

Retry strategy

No retry — fix the clock or adjust tolerance for legitimate cases

500Stripe retries webhook — HTTP 500 response from your endpoint
Cause

Your webhook handler throws an exception or returns a 5xx status. Stripe treats this as a failed delivery and retries.

Fix

Return 200 immediately and process events asynchronously using a job queue. If synchronous processing is required, implement robust error handling so your endpoint always returns 2xx even on internal errors.

Retry strategy

Stripe retries with exponential backoff for up to 3 days. After 3 days, events are no longer retried but remain accessible via GET /v1/events for 30 days.

timeoutRequest timed out — no 2xx within 30 seconds
Cause

Your webhook handler takes longer than 30 seconds to process the event. Stripe treats this as a failure and retries.

Fix

Return 200 within 1-2 seconds, then process asynchronously. Add the event to a job queue (Celery task, BullMQ job, or AWS SQS) and handle the actual notification/database update in a background worker.

Retry strategy

Same as 500 — Stripe retries for up to 3 days

401Invalid API key provided
Cause

Wrong STRIPE_SECRET_KEY when calling Stripe API endpoints (e.g., to retrieve event details or register webhook endpoints).

Fix

Verify the API key in Stripe Dashboard > Developers > API keys. Check that STRIPE_SECRET_KEY is set correctly in your environment.

Retry strategy

No retry — fix the key

Rate Limits for Stripe API

ScopeLimitWindow
API calls (live mode)100 requestsper second
API calls (test mode)25 requestsper second
Webhook deliveryStripe retriesfor up to 3 days on failure
retry-handler.ts
1import time
2import stripe
3
4def stripe_with_retry(fn, max_retries=5):
5 for attempt in range(max_retries):
6 try:
7 return fn()
8 except stripe.error.RateLimitError:
9 wait = min(2 ** attempt, 32)
10 print(f'Rate limit, waiting {wait}s')
11 time.sleep(wait)
12 raise Exception('Max retries exceeded')
  • Return 200 from webhook endpoints within 1-2 seconds — process events asynchronously via job queues to stay well under the 30-second timeout
  • Deploy your webhook endpoint with a health check and monitoring alert — downtime causes Stripe to queue retries for 3 days, creating a burst when you come back online
  • Use idempotency keys stored in Redis (SET NX with 24h TTL) to handle the retry burst safely without duplicate processing
  • Subscribe only to event types you actually handle — unused event subscriptions add noise without value
  • Use the Stripe CLI (stripe listen) for local development instead of setting up ngrok tunnels

Security checklist

  • Verify every incoming webhook signature with stripe.webhooks.constructEvent() using the raw request body
  • Store STRIPE_WEBHOOK_SECRET (whsec_*) in environment variables — never in source code
  • Use express.raw() or equivalent for webhook routes — never parse the body first
  • Reject events with timestamps older than 5 minutes (Stripe SDK enforces this by default — don't override it)
  • Implement idempotency for all webhook handlers to handle retry deliveries safely
  • Use HTTPS for your webhook endpoint URL — Stripe will not deliver to HTTP endpoints in production
  • Log rejected webhook attempts (wrong signature) with IP address for security monitoring
  • Return 200 for all valid events, even if you don't process the event type — returning 4xx causes unnecessary retries

Automation use cases

Slack Payment Dashboard

beginner

Route all payment events to a dedicated Slack channel — successes to #payments, failures to #payment-failures, disputes to #disputes with @channel mention for urgency.

Email Receipt and Confirmation

beginner

Trigger transactional email via SendGrid or Resend on payment_intent.succeeded — send order confirmation with receipt details extracted from the payment intent metadata.

Dispute Management Alert

intermediate

On charge.dispute.created, immediately create a support ticket in your CRM, assign it to the payments team, and send an SMS alert — disputes have hard 7-day response deadlines.

Real-Time Revenue Dashboard Update

advanced

Update a live revenue dashboard in real-time by pushing payment events to a WebSocket or SSE endpoint whenever payment_intent.succeeded fires.

No-code alternatives

Don't want to write code? These platforms can automate the same workflows visually.

Zapier

Free tier (100 tasks/month), paid from $19.99/month

Zapier's Stripe integration listens for payment events and can forward them to Slack, Gmail, Airtable, or 200+ other apps without writing any webhook handling code.

Pros
  • + No webhook code required
  • + Hundreds of notification destination apps
  • + 5-minute setup
Cons
  • - No raw webhook signature verification
  • - Limited event filtering logic
  • - Task costs at high payment volume

Make

Free tier (1,000 ops/month), paid from $9/month

Make's Stripe webhook trigger handles signature verification automatically, with powerful routing logic to send different events to different destinations.

Pros
  • + Visual routing between payment events
  • + Better conditional logic than Zapier
  • + Handles multiple notification channels
Cons
  • - Monthly operation limits
  • - Setup more complex than Zapier
  • - Less Stripe-specific documentation

n8n

Free (self-hosted), Cloud from €20/month

n8n's Stripe trigger node handles webhook verification and exposes the full event payload, with nodes to route to Slack, email, SMS, or custom HTTP endpoints.

Pros
  • + Full event payload access
  • + Self-hostable for data privacy
  • + Stripe trigger node handles signature verification
Cons
  • - Requires self-hosted infrastructure for free tier
  • - More setup than Zapier/Make
  • - Node-based for more complex routing

Best practices

  • Always verify webhook signatures before accessing event data — treat unverified webhooks as potential attacks
  • Use the raw request body (bytes) for verification — never the parsed JSON object
  • Return 200 within seconds and process asynchronously — Stripe considers your endpoint failed after 30 seconds
  • Implement idempotency using event.id as a deduplication key — Stripe may deliver the same event multiple times
  • Log the full event.id and event.type for every webhook received to enable debugging and audit trails
  • Subscribe to charge.dispute.created and monitor it closely — disputes have 7-day response deadlines and evidence must be submitted promptly
  • Use the Stripe Dashboard's webhook delivery logs to see which events succeeded or failed and trigger manual retries

Ask AI to help

Copy one of these prompts to get a personalized, working implementation.

ChatGPT / Claude Prompt

I'm building a Stripe webhook handler in Node.js with Express. I use stripe.webhooks.constructEvent(req.body, sig, webhookSecret) where req.body comes from express.raw(). Help me: (1) confirm the express middleware setup is correct for signature verification, (2) add idempotency checking using Redis SET NX so duplicate event deliveries are ignored, (3) handle payment_intent.succeeded, charge.failed, and charge.dispute.created events with formatted Slack notifications, and (4) implement async processing that returns 200 immediately then processes the event in a background job.

Lovable / V0 Prompt

Build a real-time payment notification dashboard in React with these features: (1) a live feed of recent payment events (succeeded, failed, disputed) updating in real-time via polling every 10 seconds from a backend API, (2) each event displayed as a card with amount, customer email, event type (color-coded green/red/orange), and timestamp, (3) a summary bar showing today's total successful payments, failed count, and dispute count, (4) a filter to show only specific event types, (5) a backend API endpoint GET /api/payments/recent that returns the last 50 events from Stripe's events API.

Frequently asked questions

Why does my webhook signature verification keep failing?

The most common cause: the request body was parsed as JSON before calling constructEvent(). In Express.js, if express.json() middleware runs before your webhook route, it parses the body and the raw bytes are lost. Fix: use express.raw({type: 'application/json'}) specifically for the webhook route, and define it before any global JSON parsing middleware. In Flask, use request.get_data() (returns bytes) not request.json (returns dict).

What happens if my webhook endpoint is down?

Stripe retries failed webhook deliveries (non-2xx response or timeout) for up to 3 days using exponential backoff. Starting intervals: immediate, +5 minutes, +30 minutes, +2 hours, +5 hours, growing from there. After 3 days, retries stop. Events remain retrievable via GET /v1/events for 30 days — use the backfill pattern in Step 4 to reprocess missed events.

Can I receive a webhook event more than once?

Yes. Stripe guarantees at-least-once delivery — the same event can be delivered multiple times, especially when your endpoint returns a 5xx or times out. Always implement idempotency using event.id as a deduplication key. Store processed event IDs in Redis (SET with NX flag and TTL) or a database unique constraint.

What event should I use for one-time Stripe Checkout payments vs subscription renewals?

For one-time Checkout payments, use checkout.session.completed. For subscription renewals, use invoice.paid (payment succeeded) and invoice.payment_failed (payment failed). For custom payment flows using Payment Intents directly, use payment_intent.succeeded. Don't use charge.succeeded — it's a lower-level event that fires for many charge types and is harder to filter correctly.

How do I test webhooks locally during development?

Use the Stripe CLI: run stripe listen --forward-to localhost:3000/webhooks/stripe. This creates a secure tunnel and forwards all events to your local server, showing you the exact payloads. The CLI also provides a webhook signing secret that you set as STRIPE_WEBHOOK_SECRET for local testing. Alternatively, trigger test events from your Dashboard under Developers > Webhooks > Send test webhook.

How urgent are chargeback (dispute) notifications?

Very urgent. When charge.dispute.created fires, you have 7 days to respond with evidence via the Stripe Dashboard or API. Missing this deadline results in automatic loss of the dispute and the charged-back amount plus a $15 dispute fee. Set up immediate alerting for dispute events — dedicated Slack channel, PagerDuty, or SMS alert.

Can RapidDev help build a custom payment notification and monitoring system?

Yes. RapidDev has built 600+ apps including real-time payment dashboards, dispute management workflows, and multi-channel notification systems integrated with Stripe. Get a free consultation at rapidevelopers.com.

RapidDev

Need this automated?

Our team has built 600+ apps with API automations. We can build this for you.

Book a free consultation

Skip the coding — we'll build it for you

Our experts have built 600+ API automations. From prototype to production in days, not weeks.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.